ADR-0081: Draft-Only Trace-Structure Changes, Server-Enforced¶
- Status: accepted (Nicolas 2026-07-18).
- Date: 2026-07-18
- Deciders: Nicolas Burri; design + implementation by Claude.
- Origin: Nicolas's directive, 2026-07-18, on the shipped relations/trace-link draft-only rule: "I am not a fan of frontend-only protections … make sure we update the backend protection as soon as reasonably possible." Recorded as a committed open thread in architecture-worldview.md ("Server-side draft-only enforcement").
- Relates to: ADR-0049 (document trace relations), ADR-0077 §5 (requirement links + suspect),
ADR-0078/-0079 (generalized/risk trace items), ADR-0080 ("a hidden tile is not access control" —
UI gates need a server
require), ADR-0075 (import red line), ADR-0068 (import pipeline), conventions.md "Lifecycle gating of affordances".
Context¶
Trace structure — document→document relations (ADR-0049) and item-level requirement/trace links (ADR-0077 §5) — was, until this ADR, draft-gated only in the UI: the relations card and the gap-closing flow offer add/remove only when the source document has a draft version. The server enforced EDIT_DRAFT in the source scope but not that the source is in an editable state, so a holder of EDIT_DRAFT could add or remove trace edges on an already-released document directly via the API. That is exactly the "hidden tile is not access control" failure ADR-0080 named: the UI reflected a rule the server did not enforce, and Nicolas has confirmed frontend-only protection is not an acceptable end state.
The governing classification is already recorded in conventions.md ("Lifecycle gating of affordances", Nicolas 2026-07-18) and invariant 5 of the worldview:
- A structure/content change (add/remove links or relations, edit content, change trace-evidence metadata) is draft-only — on a released document the proper vehicle is a revision; released evidence must not erode in place.
- An attestation act (clear a suspect flag, approve, acknowledge) is state-independent and always audited — draft-gating one creates workflow dead ends (a released corpus whose upstream moves must remain re-attestable without forcing a needless revision).
Two forces made this more than a one-line guard:
- The importer conflicts with the rule.
scripts/import/load.pyreleased each document inline and then created the convention-derived links/relations against the released sources (the target keys had to resolve, which they did once documents existed). Under the new rule those calls would fail. The conventions rule is explicit that the resolution is to resequence the importer, never to carve out a persona: "Server rules must not require bootstrap exceptions: if the importer conflicts with a rule, resequence the importer (e.g. create links while drafts, then release) rather than carve out a persona." That also honors ADR-0075's spirit — imported data carries only the import personas; adding a "may edit released structure" exception would be a fabricated capability. clearSuspectmust stay exactly as it is. It is an audited re-attestation, deliberately state-independent (ADR-0077 §5). Draft-gating it would strand a released document with a permanently-suspect link and no way to re-attest short of a pointless revision.
Options Considered¶
Option 1: Server-enforce draft-only for structure; resequence the importer (chosen)¶
Add a state guard to the user-facing structure mutations; leave attestation state-independent; resequence the importer and the seed into create → link → release. Pros: closes the gap at the authority (server), matches the recorded classification exactly, no persona exception. Cons: the importer/seed gain an ordering constraint and a documented idempotency edge case.
Option 2: Keep UI-only, revisit later¶
Rejected: Nicolas explicitly directed backend enforcement "as soon as reasonably possible"; leaving a UI-only rule contradicts invariant 5 and ADR-0080.
Option 3: A dedicated permission (e.g. EDIT_RELEASED_STRUCTURE) instead of a state gate¶
Rejected: this is not an authorization question — the caller legitimately holds EDIT_DRAFT. The document is simply in the wrong state for the act. A permission knob would also become the escape hatch the opinionated-by-default principle warns against.
Decision¶
The server rule. For user-initiated trace-structure changes — trace-link create and delete
([RequirementLinkService]) and user-edge relation add/remove ([RelationService]) — the SOURCE
document must currently HAVE a version in state DRAFT, otherwise the act is refused. The rule is "the
document has a draft", not "the caller is viewing the draft": the server is the floor; the UI
(already shipped) is stricter about where it offers the affordance.
Error shape — mirrors the life-cycle's wrong-state rejection. The guard uses the same convention as
every life-cycle state rejection (submit on a non-DRAFT, revoke on a non-RELEASED, …):
ch.lqms.scope.checkState { … } → InvalidStateException → HTTP 409 CONFLICT (mapped once
in ScopeExceptionMappers). This is deliberately not a 403: a 403 signals an authorization failure,
but the caller does hold EDIT_DRAFT — the document is in the wrong state, which is precisely 409's
meaning. (The earlier planning sketch said "403"; the correct, convention-consistent shape for a
state conflict is 409, and it is what a wrong-state life-cycle act already returns.) The message names
the rule and the remedy: "trace structure changes require a draft version; revise
Enforcement points.
- RequirementLinkService.create and .delete — a private requireSourceHasDraft(...) right after the
existing authz.require(EDIT_DRAFT).
- RelationService.add and .remove — the same helper right after authz.require(EDIT_DRAFT).
- Backed by VersionRepository.anyVersionInDraft(dsl, documentRowId) (a fetchExists, the sibling of
anyVersionInReview).
System-managed edges are exempt by construction. The derive (DeriveService) and instantiate
(InstantiateService) flows create their provenance edges (derived-from / instantiated-from) by
calling RelationshipRepository.insert(...) directly — they never pass through
RelationService.add. So the user gate lives only on the user-facing path and cannot touch an internal
lifecycle act. No method split was needed: the paths were already separate.
clearSuspect is out of scope — deliberately. It stays EDIT_DRAFT-gated, state-independent, and
untouched: clearing a suspect flag is a re-attestation ("reviewed against the new target content"),
which a released corpus must keep available. Draft-gating it would create the workflow dead end the
classification warns against.
Importer resequencing (no persona exception). load.py is resequenced into passes that keep every
existing semantic (ADR-0075 continuity/backdating/audit personas, idempotency):
- Create pass — create every document + save content (all remain DRAFT).
- Prepare pass — rewrite doc-links, re-save each draft, set per-document workflow bindings + addressees (all while DRAFT).
- Link pass —
create_trace_relations(pulsemed) +create_requirement_links, created while every source is still a DRAFT. Targets resolve from DRAFT defining versions (definingVersionRowsincludes drafts; the registry serves them). - Release pass — submit → approve → release per document (the same four-eyes flow / personas),
revoke where the bundle's target state is REVOKED, then
backdate_effective_from(ADR-0075 §2) exactly as before.
Idempotency honesty. On a completed re-run the links already exist (the link pass now precedes
release), so the source is released when the link pass runs again; such a source is skipped LOUDLY —
an aggregated, distinct released_source counter with a remedy ("source already released — a fresh
import is required to create missing links"), never blended into the generic skip counter. A genuinely
partial prior state (source released, links missing) surfaces the same loud remedy. adopt_existing_ids,
--repair-change-reasons, and the revoked/draft-only bundle paths are unaffected (they run outside the
release ordering). One documented consequence: a record→MeridianQMS-template references edge is now added
before the record's SUBMIT, so it lands as a manual edge rather than the SUBMIT-time from_text
sync's edge — the edge, its type and direction are unchanged; only the provenance flag differs (arguably
truer to the explicit-downgrade intent).
Seed resequencing. deploy/seed-demo.sh created the SWTEST→SWREQ verifies edges after releasing
the tests. release_doc is split into create_doc + drive_release; the trace section now creates the
two tests as drafts, adds the verifies edge on each still-draft source, then releases them (create →
link → release). Final state (both tests RELEASED, edges present, REQ3 uncovered) is unchanged.
UI gating now REFLECTS rather than constitutes the protection. No frontend change is required: the already-shipped UI rule is now the stricter reflection of a server-enforced floor, exactly as invariant 5 prescribes (UI reflects; server enforces).
Consequences¶
- Easier: the draft-only rule is now a true invariant, enforced at the authority; an auditor or a future client cannot mutate released trace evidence in place. The UI/server relationship matches ADR-0080's worked example.
- Harder: the importer and seed carry an ordering constraint (link before release) and one documented idempotency edge case (released source in the link pass → loud remedy, fresh import).
- Follow-up / honest cut: the guard is a has-a-draft check on the source document, not a per-version check — an edge added while a draft exists is retained when that draft releases (the edge lives on the document, not the version), which is the intended behavior (author the structure on the draft, release it). Removing an edge later still requires a draft to exist. No migration is needed (pure enforcement + importer/seed sequencing; no schema change).