ADR-0104: The Issued Dossier Is a Persisted Artifact of Record — and Clones to a New Draft¶
- Status: accepted
- Date: 2026-08-05
- Deciders: Nicolas Burri ("draft ADR-0104 with option 2… ideally I would like to be able to copy the settings of an issued export to a new dossier in draft mode. So we can have both, a precise copy of the delivered file… and create a new issue if we need updated timestamps").
- Relates to: ADR-0083 (the TD dossier — 3a built the DRAFT→ISSUED lifecycle and on-the-fly export this supersedes for issued dossiers; 3b's re-issue-as-new-version is realized here), ADR-0092 (blob storage + never-delete-referenced GC — the store this reuses), ADR-0074 (the one render path — used ONCE at issue instead of per download), ADR-0075 (version continuity), ADR-0088/0087 (the derived-view artifacts this deliberately is NOT).
Context¶
3a renders the issued dossier's PDF and evidence zip on the fly on every download. The content is reproducible (frozen pins over immutable released versions), but the file is not byte-identical across downloads — the front matter stamps a fresh "generated at" and the downloading user, so the hash changes each time. For a regulatory submission that is the wrong property: you must be able to point at the file delivered on a given date, with a stable fingerprint, whose immutability does not depend on re-running a renderer that may change across dependency upgrades. The dossier's own DRAFT→ISSUED lifecycle already frames the issued state as a record, not a view (unlike the deliberately-derived traceability report, ADR-0087) — persisting the artifact makes the framing true.
Decision¶
- Render once, at issue; store immutably.
issue()renders the merged PDF and the evidence zip through the ADR-0074 path and writes both to the content store (ADR-0092 blob boundary), keyed to the dossier version, with their SHA-256 recorded on the dossier version row. These bytes are the artifact of record; their hash is the issued dossier's identity. - Download serves the stored bytes, verbatim. An issued dossier's export returns exactly the file written at issue — same bytes, same hash, forever, independent of any later renderer change. The on-the-fly path is retired for issued dossiers. (Drafts: originally 409 — superseded the same day by the draft-watermark decision (register 2026-08-05 #2): a DRAFT may export the merged PDF on the fly with a prominent DRAFT watermark, no hash, an uncontrolled review aid. The invariant that matters: watermark present ⟺ not the record.)
- An issued dossier is a RECORD: plain deletion is FORBIDDEN (Nicolas 2026-08-05: "I would
probably not allow that"). A
DELETEon an issued dossier is refused; draft dossiers stay freely deletable (they claim nothing). Correcting a mistaken issue is correct-forward, not deletion: clone-to-draft (decision 4), fix, re-issue as a new version — the erroneous issue remains in the trail, superseded, because erasing a record is itself an integrity failure. Deliberate follow-on, NOT this decision: governed disposal under retention (the ADR-0092 record-disposal path — retention period, legal hold, audited) is the only route by which an issued dossier ever leaves the system, and it is deferred until retention policy for dossiers is a real need. "Cannot be deleted" ≠ "immortal"; today it means the former. - Clone-to-draft (3b, realized). An issued dossier offers "Create a new draft from this":
its sections and pins are copied into a fresh DRAFT. Default lineage is a new version of the
same dossier (same name,
version_number + 1— the "updated submission of the same deliverable" path, ADR-0075 continuity); cloning into a differently-named new dossier is the same mechanism with a new name (the "start one like this" path). The author re-points pins to newer released versions, edits sections, and re-issues — producing that version's own immutable artifact with its own timestamp and hash. The prior issue's stored file is untouched. Result: a lineage of issued records, each a precise copy of what was delivered.
Consequences¶
- Migration/rollout: any dossier already ISSUED under 3a has no stored artifact — a one-time render-and-store at migration, or a stated on-the-fly fallback for pre-0104 issues (decide at implementation; the demo stack likely has such a dossier).
- Storage: two blobs per issued version; GC-protected by the version row reference (ADR-0092's never-delete-referenced invariant); freed only when the dossier version is disposed.
- Audit:
DOSSIER_ISSUEDgains the artifact hashes; a new clone act (DOSSIER_CLONED/DOSSIER_REVISED) is audited with the source version. Export of an issued dossier recordsEXPORTEDas today but over stored bytes. - The
(scope, name, version_number)uniqueness already admits the version lineage; no schema reshape, one migration for the artifact columns + the clone act. - Wave sizing ≈ 1–1.5 slices (issue-time render+store, download-from-store, the disposal gate, clone-to-draft + its UI, rollout handling). Implementation queued — does NOT start with this ADR.
Amendment 2026-08-05 — realized (V079), with one decision this ADR did not take¶
§1, §2 and §4 are implemented (§3's delete guard shipped earlier the same day). Four points where the implementation had to decide something the decision above left open or unstated:
1. §2 yields to scope separation for a reader who cannot reach every pin — RATIFIED (Nicolas, 2026-08-06: "ratify as implemented")¶
§2 promises the download returns "exactly the file written at issue — same bytes, same hash, forever".
That is written as an unqualified promise, and taken literally it breaks the system's top quality goal:
a dossier's pins may target versions in OTHER scopes (ADR-0083 D-7), the curator who issued it legitimately
reached them, so the stored artifact CONTAINS foreign-scope content — and serving it verbatim would hand
that content to any VIEW holder of the dossier's home scope, including one with no reach into the pinned
scope. This ADR never discusses reach; it reasons only about hash stability. So this is a gap in the
decision, not a decision to override, and it was resolved the only way the project's non-negotiable rule
admits:
- A caller receives the stored artifact only when they can see every pin of the baseline (a sufficient condition for "this caller could have produced these bytes themselves").
- Anyone else receives the ADR-0083 3a on-the-fly render under their own reach — every pin still named,
only the reachable ones reproduced — marked as not the record: a
PARTIAL COPYwatermark on every page, a cover notice, a_partialfile name, a fresh date.
This strengthens rather than weakens the draft-watermark invariant: watermark present ⟺ NOT the artifact of record. An unmarked issued PDF is now the one file whose SHA-256 is that dossier's identity. The residual, accepted: a caller whose reach is exactly as narrow as the issuer's gets an equivalent fresh render instead of the identical stored bytes (closing it would mean recording which pins the artifact reproduced, not just how many — no auditor asked for that).
2. Storage shape: columns on the dossier row, not a dossier_artifact table¶
The format set is closed (§2 names exactly two artifacts, minted by one act, in one transaction, for one
dossier version), the artifact stamp belongs beside issued_at/issued_by, and the all-or-nothing
constraint is expressible as a row CHECK. A child table would have modelled nothing the columns do not, at
the cost of a second RLS policy and a join on the hot read path. The blob ref is the SHA-256 — the
ADR-0092 store is content-addressed, so one column per format carries both facts the ADR asks for, and
carrying them separately could only ever let them disagree.
Two columns beyond what §1 listed: artifact_pins_included / artifact_pins_excluded. Once the download
serves stored bytes, the EXPORTED event's reproduction counts are properties of the artifact (fixed by
the issuer's reach), not of the reader; re-deriving them per caller would put a number in the trail
describing a copy nobody was handed.
3. Rollout: lazy backfill, with the window narrowed structurally¶
SQL cannot render a PDF, so a migration-time backfill was never available. The first download of a pre-0104
issue renders and stores, and the record stabilizes from then on. The honest cost — for those dossiers only,
the artifact of record is what the first downloader rendered, not what was handed out on the issue date —
is made legible rather than disguised: artifact_rendered_at records the backfill instant, and the trail
carries a DOSSIER_ARTIFACT_STORED event flagged backfilled. The window is narrowed by a rule, not just
by prose: a backfill is stored only when the render was COMPLETE (no inert pins), so a partial-reach
reader can never mint a permanently-truncated record. No MANAGE_DOSSIERS gate on the backfill — the
artifact is a pure function of frozen data, so minting it decides nothing.
4. Audit shape¶
DOSSIER_ISSUED keeps the house lifecycle shape (fromState/toState, no changes body — the two are
never both populated), and the artifact's identity rides on a second event,
DOSSIER_ARTIFACT_STORED, on the same transaction. Two events because they answer different questions
("when was this baseline frozen?" / "which bytes are the record?") and because only a separate event can
also carry the backfill, which happens long after the issue. DOSSIER_CLONED is anchored on the new
dossier (a creation act, the DOSSIER_CREATED shape) with the source named, its version, its state and
which lineage was taken. EXPORTED keeps its shape exactly.
Transactional posture, as built: the state flip happens first, then the render, then the hash record — all in one transaction. Flipping first is the only order that yields a correct artifact (the cover states the issue stamp and is unwatermarked, so the bytes may only exist once the row genuinely says ISSUED), and it costs nothing because a render failure rolls the whole thing back: the dossier stays DRAFT, unstamped, unaudited. The one thing a rollback cannot undo is the content-store file write, which is by construction the ADR-0092 orphan case and is swept.