# ADR-0007: Immutable Versions with a Single Published Pointer - **Status:** Accepted - **Date:** 2024-06-02 - **Deciders:** FablePool core team - **Related:** ADR-0003 (Postgres), ADR-0006 (format), ADR-0008 (forking), `docs/architecture/04-versioning-and-forking.md`, `docs/architecture/06-review-workflow.md` ## Context Hard requirement from the brief: *every published problem/course must come from a reviewed version*, with changelogs and rollback. Additional forces: - Attempts and grades must reference the *exact* content the learner saw — a problem edited after the fact must not retroactively change what an attempt meant. - Review must examine a frozen artifact; content that mutates mid-review invalidates the review. - Rollback must be instant and safe. - Storage must stay reasonable for frequently edited content. Candidates: mutable rows + audit trail; git-backed content repository; full event sourcing; immutable version rows with pointers. ## Decision Implement the **immutable-version model** specified in `04-versioning-and-forking.md`: 1. A `Problem`/`Course` row is a lightweight identity (slug, owner, fork lineage, pointers). All content lives in `ProblemVersion` / `CourseVersion` rows that are **frozen at submission**: from the moment a version enters review, its content JSONB is never mutated. Subsequent edits create a new version. 2. Exactly **one `published_version_id` pointer** per entity, flipped atomically in the publish transaction (enforced by the partial unique index from ADR-0003). Rendering, search indexing, and the public API read only through this pointer. 3. **Drafts are mutable** until first submission — we do not version every keystroke. The draft → submitted transition snapshots and freezes. 4. **Rollback = pointer flip** to a previously published (already reviewed) version, recorded in the audit log with actor and reason. No content is ever deleted by rollback. 5. **Changelog is data:** each version carries `changelog` (author-written) plus a machine-generated structural diff against its parent version, shown in review and public history. 6. **Attempts reference `problem_version_id`**, never `problem_id` alone. 7. **Retention:** versions are kept indefinitely; payloads of superseded versions older than N years may be compressed (Postgres column compression) but never dropped while any attempt references them. ## Alternatives Considered - **Mutable content + audit log.** Cannot guarantee attempts/reviews reference frozen content; reconstruction from audit deltas is fragile. Rejected. - **Git as the content store.** Beautiful fit for diffs/forks in theory, but querying (joins with attempts, tags, reviews), transactional workflow transitions, and per-object permissions across a bare repo are all painful; running libgit2 against thousands of small JSON files under concurrent web writes is operationally scary. We instead *borrow git's mental model* (immutable snapshots, lineage pointers) inside Postgres. Rejected. - **Event sourcing (store edits, derive versions).** Maximum granularity, large complexity, and replays make schema migration (ADR-0006 §4) much harder. Rejected. - **Full snapshot per keystroke/autosave.** Storage blowup with no review value. Rejected — autosaves overwrite the single mutable draft. ## Consequences - ✅ The brief's core invariant is structurally guaranteed: the published pointer can only ever be set to a version whose status is review-accepted (database CHECK + service-layer enforcement). - ✅ Instant, safe rollback; honest version history; stable attempt semantics. - ⚠️ Snapshot (not delta) storage costs more disk; mitigated by JSONB compression and the 1 MB payload cap. Estimated cost is trivial relative to media assets. - ⚠️ "Fix a typo on a published problem" requires a new version through review. We add a **lightweight review lane** for changes whose structural diff touches only prose (no answer-spec changes): one reviewer instead of two (see `06-review-workflow.md` §"Expedited lane"). The invariant is preserved; friction is tuned.