ADR-0125: The Export Twin — Export Emits an Arrival Bundle¶
- Status: accepted (2026-08-24 — proposal
docs/planning/export-twin-proposal.mdread on the train; his ruling on the whole design including D-6's cross-scope argument: "that twin argumentation sounds really good. I'd say go for it :)") - Date: 2026-08-24
- Deciders: Nicolas Burri
- Relates to: C-4 (the one FEATURE row of v1.0 — "the corpus leaves as honestly as it
arrives"), ADR-0113 (the arrival import this twins — its bundle format and head-only floor),
ADR-0111 (external persons; the importer as "step one of the generic transfer arc" — this is
step two), ADR-0075 (the audit trail is the instance's record — the red line that draws the
fidelity boundary), ADR-0108 (re-invitation is the only way a real account exists), V106 (the
one-shot working-record tripwire, read backwards to define what does NOT travel), ADR-0083/-0074
(the pack surface reused:
document/export's PackWriter, sha256 manifest, EXPORTED-event lineage), ADR-0118 (the offline copy — the evidence-grade sibling this must not be confused with), open decisions 28/29 (base-QMS layering — constrained by §6, to be ruled WITH it).
Context¶
The import half of portability exists and is battle-proven: three full corpus arrivals through
load_arrival, a survived live interruption, and (as of the ruling-13 lane) resume verification
measured on the 293-document staging corpus. The export half did not exist by design — C-4 was
deliberately sequenced last among the big arcs. Building it as a second, independent format would
mean two new systems and a new equivalence proof. The proposal's central insight collapses that:
one exporter, aimed at a consumer that already survived production use.
Decision¶
- Export emits an ARRIVAL BUNDLE. The export's output is the same structure
load_arrivalconsumes — manifest (documents, folders, counts), per-document metadata + markdown with historical versions, attachments, version-chains, operating-model — generated from a live LQMS scope, keyed by human id instead of source pageId. The build's first act verifies structural sufficiency againstload_arrival --previewand names any gap honestly as the work list. - The unit of export is a project scope plus a DECLARED dependency manifest: the catalog rows it uses (document types, roles — by name and configuration, for the target's adopt-or-create machinery) and its cross-scope citations (listed, not embedded).
- The fidelity line: the released corpus travels; acts stay where they happened. Travels: all versions per the arrival floor, folders, attachments, trace links, releaser/date provenance. Stays: the audit trail (ADR-0075 — the target must never claim acts it did not witness), acknowledgements, approvals, comments, periodic reviews, tasks, save-points. A migration LOSES the working-record layer by design; the source instance (or its final backup) remains the archive of record. That sentence appears in the export's manifest and the operator documentation, in bold — never in fine print.
- The exporter is an in-app AUDITED act: a streaming bundle-zip endpoint on the scope,
PackWriter pattern (manifest-last completeness marker, sha256 per file), one EXPORTED audit
event, permission-gated at GLOBAL beside the import gate (own permission
EXPORT_PROJECTS; the classification guard will demand its category). Exporting is a READ: it closes nothing on the source, and an exported-then-imported corpus lands under V106's one-shot rule on the TARGET exactly like any arrival. - Identity across instances: human ids travel verbatim; persons travel as ADR-0111 external persons whose source-system origin is the SOURCE INSTANCE's identifier; real accounts never travel (ADR-0108 re-invites).
- Cross-scope references: identity re-binding, never tree export. The ownership principle —
you export what you own; you re-bind to what you reference — rejects full-dependency-tree
bundles (they duplicate the QMS wherever a copy lives and ship one party's IP inside another
party's bundle). Instead: references export as identities
(scope-code, human-id, relation kind, pinned-version where the source pinned); import re-binds them against the target via a binding map in the arrival role-mapping pattern (default same-code→same-code, operator- overridable at preview); version skew inherits the in-instance semantics (doc:references resolve to the currently-effective released version — a migrated project governs under the target QMS's effective heads exactly as if the QMS had updated in place; pinned evidence travels unchanged); unresolved references are RECORDED, never dropped (counts in the preview, visibly inert in the corpus — the XPAGE precedent). The leaving customer's completeness need is served by their bundle plus the citation-closure audit pack (evidence-grade PDFs), not by shipping the living QMS — which remains its owner's separate export.
Consequences¶
- C-4 becomes demonstrable in one command pair, and the round-trip test lands in the shape we already trust: export a production-class internal-QMS corpus → arrival-import into a second Dev-Services instance → diff the C-5 count series. The same code path proves both directions.
- §6 makes "the QMS exists on both instances" a designed state, not an accident of parallel imports: the base-QMS layer DISTRIBUTES as its owner's own export — rows 28/29's layering design must agree with this format and is to be ruled together with it.
- The bundle format is promoted from "import staging convention" to a PUBLISHED interface between instances; format changes now need the same care as API changes.
- Build shape (agreed in the proposal): format-gap audit first, exporter service + resource (PackWriter pattern), the round-trip fullstack test as the C-4 evidence artifact, then §8c "Exporting a corpus" beside §8b with §3's loss statement.
Amendment 1 (2026-08-25) — the format-gap rulings (register rows 31/32/33)¶
The format-gap audit (export-format-gap-audit.md) proved the §1 design offline and surfaced three decisions; ruled "31) ii 32) agree to extend by adding REVOKED and finally 33) b":
- §5 becomes true by ID ADOPTION, not prediction (row 31). The arrival surface gains an
id-adoption path: permitted ONLY inside an import batch on an import-born scope; the
per-(scope, type) counter is seeded past the highest adopted id; adoption REQUIRES the target
scope to adopt the source's scope code (the code is embedded in every id); a collision with an
existing id refuses loudly in the V107 grammar. Ordinary source-system arrivals keep minting
exactly as today — adoption applies only to bundles whose manifest declares their ids
authoritative. The export client's
predict_human_idmachinery retires with the conflict it worked around. - The bundle format gains
REVOKED(row 32), and the arrival surface lands it.IN_REVIEWtravels as DRAFT (the review round is an act in progress — the target restarts its review);CANCELLEDis skipped (the machine-noise precedent);REVOKEDtravels as the state it is — the revocation ACT stays at the source (§3), the resulting STATE is corpus truth, exactly as the releaser provenance already travels. Anything else either resurrects a withdrawn document on the target or silently forgets a revocation. - The bundle carries
treeJsonbeside the.md, and the loader prefers it (row 33). The native tree is the record; markdown remains the human-readable projection and the fallback for foreign bundles, which keep working unchanged. This removes the node-coverage dependency from the import path entirely — no identity proof over the projection, now or for any future node type.
Rules 2 and 3 land as ONE format version step — the first exercise of the published-interface change discipline this ADR created. The exporter's document loop is unblocked.
Amendment 2 (2026-08-27) — the base-library landing (ADR-0127 §5 executes)¶
Running the ruled migration surfaced a conflict between ADR-0127 §5 ("arrival-import the base layer into the GLOBAL scope with id adoption — references stay intact") and Amendment 1's own fine print, both halves of which were guards for the general case and wrong for the ruled one:
idsAuthoritativeextends to a scope the batch ADOPTED WHILE EMPTY. V114's import-born restriction guarded against claiming id authority over a scope with prior history — but the D1 cage already admits an adopted scope only while it holds zero documents, and an empty scope has no ids and a zero counter: adoption into it is exactly as safe as into an import-born one. V118 relaxes the constraint to cage-admitted scopes (born, or adopted empty); everything else about adoption is unchanged.- The id NAMESPACE is the bundle's DECLARED code, not the target scope's. Ids are names
(row 31); a corpus landing in the base library keeps the names its citations, training
records and printed copies already use. Adoption therefore validates ids against
scope-manifest.json's code, and landing them in a differently-coded scope is a DELIBERATE operator act behind an explicit flag — the accident the old check prevented (a wrong mapping silently re-homing ids) stays prevented, because without the flag divergence still refuses. Future native mints in the target keep the target's own code; the per-type counter is seeded past adopted numbers, so the two namespaces cannot collide. - The mandator check yields to the target's nature: the GLOBAL scope has no organization,
so a bundle's
mandatorNamedeclaration is checked only when the target HAS one.
The bundle-split tool (§5's "a script over the manifest") is product tooling: it filters a bundle to a declared subset and re-derives its manifests; the LAYER TABLE it consumes is instance data and never ships with the product.
Amendment 3 (2026-09-02) — the EXPORTED event precedes the bytes (register row 51)¶
The C-1a D-11 wave moved every streaming evidence export (the audit pack, the dossier zip) to
record its EXPORTED audit event on the read that decides the copy, committed BEFORE the first
byte, and named this bundle the one streaming export left recording it AFTER the last byte. Row
51 rules that gap closed: align the portability export with the same ordering.
The reasoning is ADR-0074's clarification, not a new one. The trail's entry is the audited issue
event: a server can record that it issued a copy, never that one was received — receipt is not
observable — so "issued" (the moment the first chunk leaves the process) is the honest fact a
distribution record can hold, and the event belongs before the stream, not after it. The export
is therefore now: a preflight that enforces the EXPORT_PROJECTS gate and resolves the download
name; then, when the container drives the stream, a PLAN transaction that reads the corpus,
records the one counted EXPORTED event and commits; then the archive, written outside any
transaction from that read (the blobs render from the immutable content store).
What this changes about §3's "exporting is a READ". Nothing about the corpus: the export still
closes, seals and mutates nothing on the source, and still performs exactly one write, its own
EXPORTED event. What moves is only WHEN that event commits relative to the bytes.
The residue, named (the D-11 residue, adopted here). The prior guarantee was "a refused corpus
transfer records nothing" — the event marked a finished export. Under the new ordering an event
can exist for a transfer that then aborted: a G-8 completeness refusal (a referenced attachment
whose bytes are gone), a broken socket, a container that never drives the stream after the plan
commits. This is the conservative over-record — it may over-state that a corpus left, never the
reverse — and it is the honest direction for a distribution-control record, exactly as the audit
pack argues. The G-8 protection is undiminished: the completeness marker manifest.json is still
written LAST and still absent from an aborted archive, so load_arrival still refuses it on sight.
The one thing that no longer distinguishes a complete export from a truncated one is the PRESENCE
of the event; the manifest does that job, and always did it better.
PortabilityExportServiceTest is re-pinned accordingly: a successful export records exactly one
counted event, and a G-8 abort now leaves that event standing over a manifest-less archive rather
than recording nothing. StreamingBundle's KDoc no longer names this bundle as the lone
divergence.
Amendment 4 (2026-09-05) — the bundle-format point release (register rows 52, 53, 55)¶
The row-37 two-instance rehearsal (row-37-rehearsal-local.md) ran the whole export→land cycle between two installations and found the corpus arriving byte-identical — and the conclusions drawn from it arriving wrong. Three rows were ruled from it ("agree with all your suggestions", 2026-09-04 evening; the eligibility line confirmed 2026-09-05).
Format 1.2, the second exercise of this ADR's own published-interface discipline (Amendment 1 was
the first). Three ADDITIVE fields on documentTypes[], so a 1.2 reader still reads every 1.1 bundle;
the fields are optional on read and mandatory on write. Full text in
arrival-bundle-format.md §1, §3, §4, §15 item 4 and
Appendix C.
verificationEvidencetravels (row 52).document_type.verification_evidence(ADR-0088) decides whether an inboundverifiesclaim reads verified or planned. With no slot for it, the rehearsal's fully-VERIFIED chain landed readingfullyVerified: 0/VERIFICATION_PLANNED_ONLYover a bit-identical corpus, with zero refusals reported and the operator's count diff clean. The corpus was right and what the system SAID about it was wrong — the one failure mode a portability format may not have. Silence was the unacceptable option; a field is cheaper than making the loss loud.retentionPeriodDays+reviewIntervalDaystravel (row 53). A records-retention obligation (ADR-0040 D3) was dropped by a migration, silently. The stored day count travels faithfully; what the TARGET's own/defaultswrite path makes of it is that path's business — register row 67's interval skew is deliberately not worked around here, because an imported period must not disagree with a hand-typed one on the same installation.- CREATE APPLIES, ADOPT REPORTS. All three are columns on the installation-wide
document_typerow. A type the import MINTS takes the declaration, through the surfaces that already govern those columns (VERIFICATION_EVIDENCE_SET,DOCUMENT_TYPE_DEFAULTS_UPDATED— MANAGE_CATALOG at GLOBAL, the gate the type creation just passed, so each sub-act still audits as itself per ADR-0113 D5). A type the catalog ALREADY carries keeps this installation's values and the difference is REPORTED — in the preview's proposal detail, in the vocabulary outcome'scatalogDivergences[], and on the operator's terminal. An arriving corpus may not re-decide, for every tenant of the receiving installation, what counts as verified — that is precisely the cross-tenant defect the 2026-08-19 ruling closed when it moved the flag's write to MANAGE_CATALOG-at-GLOBAL — and it may not drop the difference on the floor either. - The manifest's honesty counters count what is actually dropped (row 53).
crossScopeReferencesNotCarriedkeyed ontrace_link.target_scope_id, which is NULL for a document-target link even when the target document lives in another scope: the rehearsal's bundle reported0while dropping1. The counter now asks whether the target is outside the scope at all, and two siblings name the other two classes the bundle drops (documentTargetReferencesNotCarried,documentRelationsNotCarried). §6's "unresolved references are RECORDED, never dropped" is only worth anything if the record is true.
Export eligibility, clarified (row 55). GET /api/scopes/{id}/portability-export accepts a
PROJECT and the GLOBAL base library, and refuses a MANDATOR anchor with a named 400:
"An organization anchor holds no corpus — export its projects."
- The anchor holds no
documentrows by construction — memberships, assignments and its own trail live there, the corpus lives in the projects beneath it. So the thing this refusal prevents is not an error: it is a 200 with a well-formed bundle of nothing,documents: 0, a realEXPORTEDevent and a file named after the organization. An operator carrying a customer's QMS off the installation would have no reason to doubt it. - GLOBAL stays exportable, and that is a DELIBERATE CAPABILITY rather than a gap in a type check.
The base QMS layer DISTRIBUTES as its owner's own export — §6's own consequence, realized by ADR-0127
§5 and Amendment 2's namespace rules, landed into the recipient's GLOBAL with id adoption. That is the
supplier-distribution story (Ergon's base QMS reaching a customer installation), and it is why
load_arrivalhas a--land-into GLOBALpath at all. Refusing GLOBAL here would break the one distribution channel this ADR designed. - The refusal is a 400 in the V107 grammar and it happens in the PREFLIGHT, so no 200 stream ever opens and nothing is recorded. Offering the export affordance only where the server would accept it is a named follow-up on the web UI, not part of this amendment: the server refusal is the substance.
The rehearsal's remaining divergences are triaged item by item in the format document's Appendix C —
four closed here (the two counters, the DOCUMENT_IMPORTED arithmetic, the attachment units and the
batch evidence's Confluence wording), three argued and left with their reasons (the missing
installation-instance identifier, element-kind descriptions, and manifest.json standing outside its
own digest listing).