The arrival bundle format¶
Status: PUBLISHED INTERFACE (ADR-0125). This is the structure scripts/import/load_arrival.py
consumes and the structure an LQMS export emits — one format, both directions. Everything below is
written from the reader's own code and is pinned by
scripts/import/test_arrival_bundle_format.py and scripts/import/test_load_arrival.py.
- Format version:
1.3(load_arrival.FORMAT_VERSION) - Spec: ADR-0125 (the export twin; §2 the declared dependency manifest, §3 the fidelity line, §5 identity, Amendment 1 rows 31/32/33, Amendment 4 rows 52/53/55), ADR-0113 (the arrival import this format feeds, and its head-only floor), ADR-0114 (the operating model that travels), ADR-0131 §2 (the substitution matrix that travels with it — register row 76), ADR-0075 §3 (the claims record), ADR-0111 (external persons), ADR-0088 (the verification-evidence classification), ADR-0040 D3 / ADR-0038 (the two type-level clocks)
- The wire this format is loaded through:
docs/planning/arrival-import-wire.md - The gap audit that produced it:
docs/planning/export-format-gap-audit.md
Table of contents¶
- What a bundle is
- Versioning, and the change discipline
- The tree
scope-manifest.json— scope, catalog, flagsmanifest.json— documents and foldersversion-chains.json— the chainsdocs/<key>.json— the per-document sidecar- Bodies, trees and attachments
operating-model.jsonsource-training.json- Files the reader never reads
- Identity: keys, ids and adoption
- Version kinds and the chain grammar
- What the reader refuses
- What travels, what stays
- The server contract this format assumes
- Writing an exporter: the checklist
0. What a bundle is¶
A bundle is a directory holding one project scope's corpus: its documents, every version of each
that travels, the folders they sit in, their attachments, the catalog rows they need, and the
operating model that lets somebody continue working on them. It is read by load_arrival.py, which
speaks only the governed /api/admin/import surface — no SQL, no workflow driving, no personas.
Two lineages produce bundles and both are first class:
- a source-system bundle, written by the Confluence transform in
scripts/import/. It carries noscope-manifest.json; everything not in the directory comes fromscripts/import/sources.py. This is the LEGACY shape and it is read byte-for-byte as it always was. - an LQMS-origin bundle, written by the ADR-0125 exporter. It carries a
scope-manifest.json, which makes it self-describing: no repository, noSourceConfig, no Confluence directory.
The difference is one file. Everything else in this document applies to both.
1. Versioning, and the change discipline¶
scope-manifest.json.formatVersion is <major>.<minor>.
| Step | Means | The reader does |
|---|---|---|
| MAJOR | a field this reader knows now means something else | REFUSES. Reading it anyway would land a corpus that looks plausible and says the wrong thing. |
| MINOR | ADDITIVE only — new optional fields, new permitted values of an existing one | reads it. A forward minor logs a warning and IGNORES what it does not know. |
That asymmetry is what lets an exporter add a field without stranding every installed loader, and it
is why a minor step may never change the meaning of an existing field or make an optional one
required. A bundle with no scope-manifest.json declares no version and is read as legacy.
ADR-0125 promotes this format to a published interface: a format change now needs the same care as an API change. In practice — bump the minor, state the new field here with its behaviour-when-absent, pin it with a test, and keep the reader able to read every 1.x bundle.
Version history:
| Version | Change |
|---|---|
| 1.0 | the format as the arrival import consumed it, plus scope-manifest.json (ADR-0125 §2 / gap G-1), the optional snapshot/ (G-2) and the titleRule (G-4) |
| 1.1 | REVOKED as a version kind (Amendment 1 rule 2) and versions[].treeJson (rule 3) — the ONE step those two rulings land as |
| 1.2 | documentTypes[].verificationEvidence, .retentionPeriodDays and .reviewIntervalDays (Amendment 4, register rows 52/53) — the three GLOBAL catalog properties a corpus's meaning depends on and a 1.1 bundle had no slot for |
| 1.3 | operating-model.json.substitutionEdges[] (ADR-0131 §2, register row 76) — the declared emergency-cover matrix, the one operating-model member that had never travelled |
The 1.3 compatibility statement¶
A 1.3 reader accepts every 1.2 bundle, unchanged. The new field is ONE optional array on
operating-model.json, and no existing field's meaning moves — the MINOR rule above, applied literally.
Absent means exactly what a 1.2 bundle's silence always meant: this corpus declares no emergency
cover. It never means cover that could not be read, because before 1.3 there was no slot to lose it
from. A 1.2 reader meeting a 1.3 bundle behaves as §1's table says — it warns and ignores what it does
not know — so the matrix stays behind exactly as it did before this step, which is the pre-1.3 behaviour
and therefore no worse than before.
Why it needed a format step (register row 76). ADR-0131 §2 put the matrix in the operating model and said in as many words that it "travels in the arrival bundle with the rest of it (format extension at build time), beside role mapping and review policies". The format extension was never built, so the matrix was the operating-model member that did NOT travel — and it did not travel silently: a customer landing an operating model received roles, review policies and per-document bindings, no emergency cover, and nothing in the bundle, the preview or the run report that said so. The ADR's own argument — that an organization's paper substitution rules become declared, exported, auditable system configuration — rests on the travel, so the gap made the ADR untrue rather than incomplete.
Mandatory on write, like 1.2's classification. An LQMS export states substitutionEdges on every
bundle, [] included: "this corpus declares no emergency cover" is a claim the bundle makes, not a
silence the reader has to guess at.
The 1.2 compatibility statement¶
A 1.2 reader accepts every 1.1 bundle, unchanged. The three new fields are
optional on read — absent means exactly what a 1.1 bundle's silence always meant, that the target's
own catalog governs — and mandatory on write: an LQMS export states verificationEvidence on every
type row (false is a claim the bundle makes, not a silence the reader has to guess at) and states each
clock the catalog actually holds. A 1.1 reader meeting a 1.2 bundle behaves as §1's table says: it
warns and ignores what it does not know, which is the pre-1.2 behaviour and therefore no worse than
before — the loss is the one this step exists to close, so a target should be upgraded before a corpus
whose classification matters is landed on it.
Why these three needed a format step at all (the row-37 rehearsal, docs/planning/row-37-rehearsal-local.md
D1 and D2). document_type.verification_evidence (ADR-0088) decides whether an inbound verifies claim
reads verified or merely planned. A rehearsal corpus whose Verification Reports were classified as
evidence in the source landed in the target with the flag off: GET /scopes/{id}/requirement-chains
answered total=2, satisfied=1, fullyVerified=0 with VERIFICATION_PLANNED_ONLY where the source
answered fullyVerified=1 — over a corpus that was bit-identical, with the landing reporting zero
refusals and the operator's count diff coming out clean. retention_period (ADR-0040 D3) disappeared the
same way, dropping a records obligation silently. The corpus was right and the conclusion drawn from it
was wrong, which is the one failure mode a portability format may not have.
2. The tree¶
flowchart TD
R["bundle root/"] --> SM["scope-manifest.json<br/>scope · catalog · flags"]
R --> M["manifest.json<br/>documents · folders"]
R --> VC["version-chains.json<br/>per document: the versions that travel"]
R --> D["docs/"]
R --> A["attachments/"]
R --> OM["operating-model.json<br/><i>optional</i>"]
D --> DJ["<key>.json<br/>per-document sidecar"]
D --> MD["<key>.v<N>.md<br/>the version body"]
D --> TJ["<key>.v<N>.tree.json<br/><i>optional — the native tree</i>"]
A --> AK["<key>/<file><br/>attachment binaries"]
SM -. "absent ⇒ legacy bundle,<br/>read from sources.py" .-> M
Required at the root: manifest.json, version-chains.json, docs/. Everything else is optional,
and every "when absent" below is measured behaviour, not intent.
The legacy layout additionally has a sibling snapshot/ directory (snapshot/pages.json,
snapshot/attachments/<key>/). It is optional as of 1.0. A bundle written to this format needs
none of it: pages.json is read when present and contributes nothing a well-formed manifest.json
does not already carry, and attachments have their own home at the root.
3. scope-manifest.json — scope, catalog, flags¶
The ADR-0125 §2 declared dependency manifest. Present ⇒ it is the AUTHORITY for everything that
is not per-document. Absent ⇒ the legacy path reads all of it from sources.py::SourceConfig.
Never a merge of the two: a half-declared bundle would land a corpus named partly after itself and
partly after the importing repository's vocabulary.
| Field | Required | Meaning / when absent |
|---|---|---|
formatVersion |
yes | <major>.<minor> — see §1. Missing or unparseable ⇒ refusal. |
scope.code |
yes | the scope code the corpus lands under, and the head of every human id. Also the default for --scope-code. |
scope.name |
no | the scope's display name. Absent ⇒ the source config's. |
scope.type |
no | PROJECT | GLOBAL. Absent ⇒ the source config's. |
scope.mayBeDeriveSource |
no | ADR-0074 derive-source flag. Absent ⇒ the source config's. |
scope.mayHoldRecords |
no | declared; sent on the target payload only when present (server contract, §15). |
scope.mandatorName |
no | the organization the corpus belongs to, for the operator's check against --mandator-id. Sent on the target payload only when present. |
idsAuthoritative |
no (default false) |
row 31. true ⇒ the bundle's document keys ARE the ids; they are adopted, not predicted. See §11. |
titleRule |
no | verbatim | source-de-prefix. Default when a manifest is present: verbatim. See §4. |
lossStatement |
yes when idsAuthoritative |
ADR-0125 §3's sentence, in Markdown, in bold. See §14. |
sourceSystem |
no | the ADR-0111 origin key every EXTERNAL person of this bundle is minted under. Absent ⇒ --source-system, else the installation's configured default. |
originLabel |
no | the free label naming this concrete snapshot; rides into every DOCUMENT_IMPORTED payload. Absent ⇒ the legacy Confluence space <key>, snapshot <date>. |
sourceKeyPrefix |
no (default confluence) |
the prefix of the ADR-0113 D6 replay key: <prefix>:<document key>. An LQMS export sets e.g. lqms:<instance-id> — that is what makes a re-export distinguishable from a Confluence arrival. |
documentTypes[] |
yes when present | the catalog rows this corpus needs. See below. |
elementKinds[] |
no | {slug, families:[{label, prefix}]}. Absent ⇒ derived from the union of the types' mayDefine, with the key families computed from the corpus's own requirementItems (register #49c). |
roles[] |
no | {name, description} — the role VOCABULARY. Merged into the operating model's roles: a role the manifest names and no policy uses arrives with no functions (which is honest, and keeps an addressee-only role from being dropped). Never overwrites a role's functions. |
generator, generatedAt |
no | provenance for a human reader; never read. |
documentTypes[] rows:
| Field | Required | Meaning |
|---|---|---|
code |
yes (or prefix) |
the token manifest.json's documents[].docType uses |
prefix |
yes | the human-id type prefix (SOP in MERIDIAN-QMS-SOP-001) |
name |
no | the catalog row's display name. Absent ⇒ code. |
category |
no | PRESCRIPTIVE | RECORD | … — the target's adopt-or-create input |
description |
no | absent ⇒ no description on the wire (the field is nullable) |
mayDefine[] |
no | the element kinds documents of this type may DEFINE. Load-bearing: the trace registries walk the frozen content of defining types only, so a corpus whose requirements arrive under a type that may not define them lands its prose and no requirements at all. |
verificationEvidence |
no (1.2) | ADR-0088: does a RELEASED document of this type count as VERIFICATION EVIDENCE? Absent ⇒ the target's own value (the column default false on a type this import creates). An LQMS export always states it. |
retentionPeriodDays |
no (1.2) | ADR-0040 D3: the type-level records-retention period, in whole days. Absent ⇒ the target's own (none, on a created type). |
reviewIntervalDays |
no (1.2) | ADR-0038: the type-level periodic-review interval, in whole days. Absent ⇒ the target's own. |
A docType no row declares is a refusal naming the type and listing the declared ones. (Before
1.0 it was a bare KeyError on a Python dict.)
The three 1.2 fields are GLOBAL, and that governs what the target does with them (§15 item 4).
verification_evidence, retention_period and review_interval are columns on the installation-wide
document_type row — one value for every tenant — not on the per-scope activation. So the rule is
create applies, adopt reports: a type this import MINTS takes the declaration (through the surfaces
that already govern those columns, each audited as itself); a type the catalog ALREADY carries keeps the
target's values and the difference is REPORTED — at preview, in the vocabulary outcome and on the
operator's terminal. An arriving corpus may not silently re-decide what counts as verified for every
tenant of the receiving installation; it may also not drop the difference on the floor.
4. manifest.json — documents and folders¶
| Field | Required | When absent |
|---|---|---|
documents[] |
yes | KeyError |
documents[].pageId |
yes | the corpus-wide document key (§11). The name is a historical Confluence-ism; the value is any stable unique string. |
documents[].title |
yes | the document's title, read under titleRule |
documents[].docType |
yes | a code of the declared catalog (§3) |
documents[].ancestors[] |
no | the document lands at the scope root |
documents[].isContainer |
no | treated as a leaf; true makes the document a folder for its children too (never its own parent) |
folders[] |
yes (may be []) |
KeyError |
folders[].pageId, .title |
yes if any folder row exists | KeyError |
transformed_at |
no | only used to date the legacy originLabel |
counts, folders[].ancestors, folders[].position, documents[].state, documents[].ticketKey |
— | never read. Folder ORDERING does not travel. |
counts is written for the OPERATOR, and its honesty counters say what could not be carried. The
reader ignores the whole block; an auditor does not, and the numbers a migration is diffed against come
from here. Beside documents / versions / attachments / folders, an LQMS export writes three
loss counters (register row 53 corrected the first and added the other two):
| Counter | Counts |
|---|---|
crossScopeReferencesNotCarried |
trace links whose target is outside this scope — by target_scope_id, or by naming a target DOCUMENT that is not one of this scope's. The second disjunct is the row-53 fix: target_scope_id is NULL for a document-target link even when the target lives elsewhere, so the counter used to answer 0 while dropping one (the rehearsal's D3). |
documentTargetReferencesNotCarried |
in-scope links whose target is a DOCUMENT rather than a requirement KEY — no slot (Appendix A item 8). |
documentRelationsNotCarried |
ADR-0021/ADR-0049 document RELATIONS the bundle carries nowhere (the rehearsal's D4). A MANUAL edge is always counted; a from_text edge is counted only when its target is outside the scope, because an in-scope one is re-derived by the far side's own freeze — counting it would over-state the loss as badly as the old cross-scope counter under-stated it. |
attachments here counts distinct FILES; load_arrival's run report counts upload ACTS, so a
file pinned to two versions is 1 in the manifest and 2 in the report. Both are right; the rehearsal's D6
was that neither was labelled. Both are now.
titleRule. source-de-prefix (the legacy default) drops a redundant <AREA>-<TYPE>- head and
replaces every hyphen with a space. That is a Confluence-ism, and it is unconditional: it rewrites
Post-Market Surveillance → Post Market Surveillance and Risk-Benefit Analysis → Risk Benefit
Analysis. verbatim — what a manifest-bearing bundle declares by default — carries the title exactly
as the bundle spells it, which is what makes export→import idempotent on the one field a human reads
first (gap G-4).
Titles are the fallback idempotency key, so they must be unique across the corpus. Under
source-de-prefix, a de-prefixing collision makes both documents keep their ORIGINAL titles.
5. version-chains.json — the chains¶
{"chains": {"<document key>": {"versions": [...]}}}. Every document in manifest.json needs a
chain, and every chain at least one arriving version — both are refusals otherwise.
| Field | Required | When absent |
|---|---|---|
versions[].kind |
yes for an idsAuthoritative bundle; optional otherwise |
absent ⇒ silently DRAFT on a legacy bundle (gap G-8's second silent acceptance) and a refusal on an authoritative one. See §12. |
versions[].body |
yes | KeyError — a path RELATIVE TO THE BUNDLE ROOT |
versions[].treeJson |
no | 1.1. A path, relative to the bundle root, to the native ProseMirror tree. Present ⇒ posted as it stands. Absent ⇒ the tree is derived from the markdown. See §7. |
versions[].version |
no | the server numbers the chain 1, 2, 3 … |
versions[].at |
no | the release stamp falls back to evidence.approvedAt only |
versions[].by |
no | the version is authored by the OPERATOR — never by anyone invented |
versions[].evidence.approvedAt |
no | falls back to versions[].at |
versions[].evidence.approvedBy | approverName | releasedBy |
no | the releaser becomes the source-system placeholder <origin>@<origin>.invalid — visibly synthetic, structurally unmailable |
versions[].evidence.grade |
no | preview cosmetics; not on the seal wire |
versions[].evidence.draftCause |
no | the value automatic-conversion SKIPS the version (ADR-0113 Amendment 1: the Atlassian macro migration's own edit is machine noise, not a draft anybody wrote). An LQMS export must never emit it. |
versions[].caveats[] |
no | []. An LQMS export's is empty: attachments ARE version-pinned here. |
versions[].imageRefs / attachmentLinks / diagramSources / diagramNodes |
no | falls back to the same keys on docs/<key>.json, then to none. See §7. |
versions[].role, isCurrentBody, sourceTitle, requirementKeys; chains[k].title / docType / outcomes; top-level stats |
— | never read |
evidence.approvedBy may be a string or {name, email}. A recorded human approver is used whenever
the evidence carries one; nothing else is ever promoted into that role — saving a version and
releasing it are different acts, and the format keeps them apart.
6. docs/<key>.json — the per-document sidecar¶
The FILE is required for every document. Every field in it is optional ({} is accepted).
| Field | Becomes |
|---|---|
sourceUrl |
the source of each claims-record entry; the document's provenance link |
sourceHistory[] |
the ADR-0075 §3 claims record, prepended to the chain-derived entries. {version, at, by, action, kind, source}. It may carry version and release FACTS, never ACTS BY PERSONS — see §14. |
changeSummary |
changeDescription on the first version |
roles {author, reviewer, releaser} |
the ADR-0114 D5 per-document workflow binding, in SOURCE role names |
addresseeRoles[] |
the acknowledgement audience. [] is a CLAIM ("nobody is addressed"); absent is a SILENCE (the type policy's pre-fill stands) |
trainingMode |
the per-document ADR-0070 §3 override |
trainingRelevant |
rides the last RELEASED version only — never a REVOKED one, which is not a release anybody must be trained on |
requirementItems[] {key, kind, category} |
the element-kind KEY FAMILIES (register #49c). Never travels as a document field — a category is derived from the key wherever it is read. |
requirementLinks[] {type, sourceRequirementKey, targetRequirementKey, provenance} |
typed trace claims (ADR-0085) |
imageRefs / attachmentLinks / diagramSources / diagramNodes |
the per-version attachment fallback |
allInternal |
carried; unused downstream in this client |
pageId, title, docType, state, reviewDue, statusName, position, sourceVersion, ticketKey, releasedVersion, nextReview, purpose, scope, pageLinks, drawio, isContainer |
never read |
7. Bodies, trees and attachments¶
Bodies and trees¶
Every version has a markdown body at versions[].body. It may additionally carry its native
tree at versions[].treeJson.
Precedence is per version:
| The version carries | The loader posts | deploy/md-to-tree.mjs |
|---|---|---|
treeJson |
the tree file's bytes, verbatim, with the .md as its projection |
not run for that version |
no treeJson |
a tree derived from the markdown | run (one batched call for the whole corpus) |
A bundle may mix the two; a bundle that carries every tree never starts node at all. This is Amendment 1 rule 3: the native tree is the record, markdown is the human-readable projection and the fallback for foreign bundles. It removes the node-coverage dependency from the import path entirely — nothing about fidelity then rests on what the markdown projection happens to express, now or for any future node type.
Two rules keep a carried tree honest, and both are refusals:
- a stated
treeJsonthat is missing or unparseable is refused, never silently replaced by the derived tree — the version named its tree as the record; - a version that carries a tree AND markdown the
doc:PAGE-<n>/doc:XPAGE-<n>rewriter would CHANGE is refused: rewriting the markdown without rewriting the tree would freeze a version whose record and whose readable body cite different documents. A bundle carrying trees carries ids, not markers.
The ADR-0072 image→diagram swap is a repair of the DERIVED tree only. A native tree already has its
diagram nodes and is never subjected to it.
doc: links in a body¶
doc:PAGE-<n> is rewritten to the arriving document's id; doc:XPAGE-<n> to the other space's, via
--xpage-map. Both regexes are digit-anchored, so doc:<HUMAN-ID> passes through untouched — an
exported body needs no rewriting and gets none.
A doc: reference to a document in ANOTHER scope is, today, inert text on arrival: it degrades with
no count and no record. That is gap G-10 and it is explicitly out of this format's 1.x scope
(ADR-0125 §6, sequenced after the single-scope round trip).
Attachments¶
Binaries live at attachments/<document key>/<filename>, relative to the bundle root. The legacy
snapshot/attachments/<document key>/ is the fallback and is used whenever the root attachments/
directory does not exist.
Which files a version uploads is imageRefs + attachmentLinks + diagramSources +
diagramNodes[].srcFile, read from the VERSION first and from docs/<key>.json as a fallback, in
that order, deduplicated.
A referenced file with no bytes on disk:
- on a legacy bundle it is DROPPED, silently — the right answer for a Confluence snapshot, where a page may cite an attachment the fetch could not retrieve and the corpus is still the best record available;
- on an
idsAuthoritativebundle it is a refusal naming the file (gap G-8): the exporter wrote both the reference and the store, so a missing binary means the bundle is corrupt, not sloppy, and importing anyway would freeze a body citing content that will never arrive.
Attachments land BEFORE the seal — the frozen-tree discipline: the version manifest must pin them.
8. operating-model.json¶
Optional. Absent ⇒ the ADR-0114 flow is skipped and the project arrives with no roles and no review policies (loudly logged). Consumed:
roles[] {name, description, functions[] {docType, slots[]}}policies[] {docType, authors, reviewers, reviewerMinCount, releasers, revise, revoke, cancel, addressees, fourEyes, releaseMode, typePrefix, typeName, documentCount}unconfiguredTypes[] {docType, documentCount, reason}substitutionEdges[] {sourceRole, targetRole}— 1.3
Never read: generated_at, source, scopeCode, trainingBindings, counts.
The model is NARROWED to the types this run actually imports, then extended by
scope-manifest.json's roles[] (§3). Every source role must be resolved by the operator through
the two-step mapping flow (proposed-role-mapping.json → --mapping) before a single document
arrives.
substitutionEdges[] — the declared emergency-cover matrix (1.3, ADR-0131 §2)¶
"In this project, a holder of sourceRole may take over targetRole in an emergency." Governance
configuration of the same rank as a review policy, and the same three rules apply to it as to everything
else in this file:
- Roles are named, never identified by id. Both ends are SOURCE ROLE NAMES, resolved through the confirmed mapping exactly as a policy's slot lists are — the mapping table is keyed by that same name, and a role UUID would name the exporting installation's catalog. No field of this format carries one.
- The edge's ends JOIN the set the operator must confirm. A name reaching the model only through an edge is still a role of the model, so the operating-model act refuses while it is undecided ("nobody has decided what 'X' becomes here"). Nothing about a role is adopted silently — ADR-0114 §2, through a new door.
- The DECLARATION ACT does not travel.
substitution_edgerecords who judged the source's holders competent and when; those are acts the source instance witnessed, and §14 keeps them there. The receiving operator declares each edge in their own name, through the same audited act the UI performs — which is ADR-0131 §2 taken literally: the declaration act IS the qualification statement, and a judgment can only be made by whoever makes it.
Behaviour when absent: no emergency cover is declared (see §1's 1.3 compatibility statement). An LQMS
export always states the array, [] included.
Two landing outcomes are REPORTED rather than raised, because both are the operator's own confirmed mapping talking back and the only remedy is to re-map: an edge whose two ends collapse onto one instance role (many-to-one mapping is the normal case — see §8's mapping flow), and an edge whose target resolves onto an ADMINISTRATIVE-class role of the receiving installation, which ADR-0131 §3 refuses structurally. The run prints each with the refusing surface's own sentence; the corpus lands. An edge grants nothing by itself — it authorizes a future timed, witnessed, self-expiring act — so a project that lands one edge short is visibly incomplete and one governance act from complete, which is not the class of loss that justifies refusing a corpus.
9. source-training.json¶
Optional. {"documents": {"<key>": {"completions": [...]}}, "stats": …, "unmatchedDocuments": …,
"persons": …}. An entry that exists without completions is a KeyError.
An LQMS-origin bundle must contain none, and one that does is refused. This is the single slot in
the format that would smuggle an ADR-0125 §3 "stays" item: completions are training ACTS — who
acknowledged which document, and when — and the obvious server-side fill for them on export is the
acknowledgement table. §3 puts acknowledgements on the side that stays. A legacy Confluence bundle's
claims record is exactly what ADR-0075 §3 built the slot for and travels unchanged.
10. Files the reader never reads¶
conversion-report.json, diagrams/, and pageid-map.json (except when explicitly named by
--xpage-map). They may be present; they change nothing.
deploy/md-to-tree.mjs plus the webui node_modules are needed by the READER (not the format)
wherever any version arrives without a treeJson.
11. Identity: keys, ids and adoption¶
The document key¶
manifest.documents[].pageId is the corpus-wide key. It names the chain in
version-chains.json, the sidecar docs/<key>.json, the body files docs/<key>.v<N>.md, and the
attachment directory attachments/<key>/. Nothing parses it as a number. A human id works unmodified
in all four places.
Predicted ids (the default)¶
An ordinary source-system bundle's ids are minted by the target from its own per-(scope, type)
counter. The loader PREDICTS them — <SCOPE>-<PREFIX>-<nnn> from a dense sequence — because a body's
doc: links must be rewritten to real ids BEFORE the version is created and the arrival surface has
no re-save. Prediction is a claim, so every create answer is verified against it and a miss is
reported loudly (it cannot be repaired: a frozen version's bytes land once).
Adopted ids (idsAuthoritative: true) — Amendment 1 rule 1¶
The bundle's keys ARE the corpus's names. The loader stops predicting entirely and asks the target to adopt each id verbatim. Three things are checked before a single call is made:
- the target code. Every human id embeds its scope code, so a bundle of
MERIDIAN-QMS-*ids refuses to land in a scope called anything else. The refusal names both codes and the remedy (--scope-code MERIDIAN-QMS). Adoption REQUIRES the target to adopt the source's code. - the shape of every key, which must be
<scope code>-<type prefix>-<designator>for the type the document claims. A key that is not an id cannot be adopted. - the bundle is LQMS-origin: it carries the bold loss statement (§14) and no
source-training.json(§9).
Why this matters more than it looks: a source scope with ANY gap in its numbering — one disposed or
cancelled document is enough — comes back RENUMBERED under prediction, and from the first gap onward
every frozen body carries the wrong doc: targets. Adoption has no sequence to be wrong about.
The adopted id is also the REPLAY KEY, and that has a consequence for what a bundle may contain.
An ordinary bundle is replayed against (document type, title) — the only key a client whose ids the
target mints can offer on a first run — so two documents of one type under one title collapse into
one there, by construction. An authoritative bundle is replayed against the id it names, so it may
carry as many same-titled documents of a type as the corpus really has (review minutes, test reports,
supplier assessments): each lands under its own name and each resumes as itself. The other half of
the same rule: one id may be named by exactly one document. Two documents claiming one id is a
defective export — refused at preview, and refused again at the second document if the preview was
skipped.
12. Version kinds and the chain grammar¶
versions[].kind is one of:
| Kind | Sealed? | Meaning |
|---|---|---|
RELEASED |
yes | released in the source, carrying its source release date and releaser |
REVOKED |
yes | 1.1. Released in the source and later withdrawn there. The resulting STATE is corpus truth; the revocation ACT stays at the source. |
DRAFT |
no | an open draft — the version arrives by NOT being sealed |
Two source states deliberately have no kind, and an exporter maps them:
IN_REVIEW→DRAFT. The review round is an act in progress: it stays where it is happening, and the target restarts its own review. (The submitter, the round, the approvals are all working-record layer — §14.)CANCELLED→ skipped, on the machine-noise precedent: a cancelled version is not a state of the corpus anybody needs on the far side.
The chain grammar¶
flowchart LR
V1["v1 RELEASED<br/><i>sealed</i>"] --> V2["v2 REVOKED<br/><i>sealed</i>"] --> V3["v3 DRAFT<br/><i>unsealed</i>"]
V3 -. "no successor is possible:<br/>an append behind an unsealed<br/>version is refused" .-> END(("end of<br/>the chain"))
Each version is created UNSEALED, receives its attachments, and is then sealed in the state the
source had it in — RELEASED, REVOKED, or left as the DRAFT it is.
A version may only be APPENDED behind a sealed predecessor. That is not a rule this format adds;
it is the document model's own "one non-terminal version" invariant, and it is what makes
ADR-0113 D3's "a DRAFT only as the LAST version of a chain" structural rather than declarative.
RELEASED and REVOKED are both sealed, so both are legal predecessors — which is what makes
v1 RELEASED, v2 REVOKED, v3 DRAFT expressible at all. (The gap audit found that chain
unrepresentable; that is what Amendment 1 rule 2 fixed.)
Version numbers are honoured when stated and must strictly increase. A source chain legitimately runs 9 → 11 → 14: the gaps are versions the source could not prove, and they stay in the claims record rather than being invented.
A REVOKED version seals with the SAME release provenance a RELEASED one does, because it WAS released. What never travels is the revocation act — no revoker, no revocation date, no reason.
13. What the reader refuses¶
Every refusal is in the V107 grammar: the delta NAMED, why continuing would be worse than stopping, and a remedy the operator can actually perform.
| Refusal | Applies to |
|---|---|
formatVersion unparseable, or of another MAJOR |
any manifest-bearing bundle |
titleRule this reader does not know |
any manifest-bearing bundle |
a docType no catalog row declares |
all |
| a document with no chain, or a chain with no arriving version | all |
a stated treeJson that is missing or unparseable |
all |
a carried tree beside markdown the doc: rewriter would change |
all |
a version with no usable kind |
idsAuthoritative only |
| a referenced attachment with no bytes | idsAuthoritative only |
no bold lossStatement |
idsAuthoritative only |
a source-training.json is present |
idsAuthoritative only |
| the target scope code differs from the bundle's | idsAuthoritative only |
| a document key that is not a well-formed id | idsAuthoritative only |
| the target adopted a DIFFERENT id than the one asked for (collision, or a target too old to know the field) | idsAuthoritative only |
| an adopted document is already here in a state this bundle cannot complete | all (the resume gate) |
The pattern is deliberate: a legacy bundle's ambiguity is tolerated, an LQMS-origin bundle's is not. A Confluence snapshot is the best record available of a system nobody controls. An export is written by software that knows the answer, so a gap in it is a defect in the exporter, and finding it at import time — loudly — is the cheapest place to find it.
14. What travels, what stays¶
ADR-0125 §3, and it is the sentence a recipient most needs:
A migration LOSES the working-record layer by design. The audit trail, acknowledgements, approvals, comments, periodic reviews, tasks and save-points do not travel; the source instance (or its final backup) remains the archive of record for them.
| Travels | Stays |
|---|---|
| all versions per the arrival floor | the audit trail (ADR-0075: the target must never claim acts it did not witness) |
| folders (not their ordering) | acknowledgements |
| attachments | approvals and review rounds |
| trace links (in-scope) | comments |
| releaser + release-date provenance | periodic reviews |
| the operating model (roles, policies, bindings) | tasks |
the source's own claims record (sourceHistory) |
save-points |
| the types' GLOBAL catalog properties — classification + the two clocks (1.2), applied on create and reported on adopt | document RELATIONS (counted, §4) |
| the declared substitution matrix — which role may cover which (1.3, ADR-0131 §2) | who declared an edge, and when — the qualification judgment is re-made by the receiving operator as their own audited act |
An idsAuthoritative bundle must carry that statement in scope-manifest.json.lossStatement, in
bold, and the loader reads it out to the operator before the preview. A bundle without it is
refused. ADR-0125's own words: never in fine print — and a statement that only ever lives inside a
JSON file nobody opened is fine print with extra steps.
None of the "stays" column has a field anywhere in this format. That silence is a structural guarantee, not an accident — with the one exception §9 fences off.
sourceHistory is the boundary worth stating twice: it is the legitimate carrier of release
provenance, and it is also exactly the shape audit rows would fit into. The line is that it may carry
what state a version reached and when, never who approved, commented or acknowledged.
15. The server contract this format assumes¶
load_arrival.py speaks /api/admin/import (docs/planning/arrival-import-wire.md). Format 1.1
needed three things from that surface, and 1.2 a fourth. Each is pinned as executable contract in
FakeArrival (scripts/import/test_load_arrival.py) as well as in the server's own tests.
1. Id adoption (row 31).
POST /api/admin/import/batchesacceptsidsAuthoritative: trueon the target payload. It is a property of the BATCH, permitted only on an import-born scope.POST …/batches/{id}/documentsacceptsadoptHumanId— the id to adopt verbatim. It is a SEPARATE field fromhumanId, which keeps its ADR-0113 D6 replay-key meaning ("never a request to mint a particular id"); overloading it would make an adoption request and a replay key indistinguishable on the wire.adoptHumanIdon a batch that did not declareidsAuthoritative→ 400.- No
adoptHumanIdin an authoritative batch → 400. - An
adoptHumanIdthe scope already holds → 409 (the collision the ADR says refuses loudly). - After an adoption the per-(scope, type) counter is seeded past the adopted number, so the next minted id continues after the highest adopted one.
- On such a batch
adoptHumanIdis ALSO the D6 replay key, and the(type, title)fallback does not apply: a re-sent document resumes the document that carries that id, and a document naming an id the scope does not hold is created however it is titled. Two documents of one type under one title are two documents when they name two ids. - The DRY RUN sees the field too —
POST …/previewacceptsadoptHumanIdper document and refuses, before anything is written, a document of an authoritative bundle that names no id and an id named by more than one document. Both are refusals of the act as well; the preview is where they are cheap, because documents land one committed row at a time.
2. REVOKED as a seal state (row 32).
POST …/versions/{id}/sealaccepts{"state": "REVOKED", "sourceReleasedAt": …, "sourceReleaser": …}and lands the version in stateREVOKED, carrying the same release stamps aRELEASEDseal carries. No revoker, no revocation date, no event claiming this instance withdrew anything.REVOKEDis sealed: it seals once, refuses a second seal, refuses attachments.appendVersionaccepts aREVOKEDpredecessor exactly as it accepts aRELEASEDone. Today it refuses anything that is notRELEASED; the honest condition is not unsealed.
3. Native trees (row 33). Nothing new on the wire — treeJson is already the create/append
payload's field. What changes is that the client may send the bundle's OWN tree rather than one it
derived, so the tree the format carries must be accepted and validated exactly as a derived one is.
4. The three GLOBAL catalog properties (register rows 52/53, format 1.2).
POST …/batches/{id}/vocabularyacceptsverificationEvidence,retentionPeriodDaysandreviewIntervalDayson eachdocumentTypes[]row.- On a type this call CREATES, the declaration is APPLIED — through the surfaces that already own
those columns (
VERIFICATION_EVIDENCE_SETandDOCUMENT_TYPE_DEFAULTS_UPDATED, both MANAGE_CATALOG at GLOBAL, the same gate the type creation just passed), so each sub-act audits as itself (ADR-0113 D5) and no new authority is asked for.verificationEvidence: falseon a created type writes nothing:falseis the column default, and a write with no change is not a change (ADR-0095). - On a type it ADOPTS, nothing is written and every difference is REPORTED, as
documentTypes[].catalogDivergences[]in the 200 body — one sentence per differing property, naming what the bundle declared, what this installation holds, and which one governs. POST /api/admin/import/previewanswers the same difference in theDOCUMENT_TYPEproposal item'sdetailmap, so the operator reads it BEFORE the one-shot window closes rather than inferring it from a coverage matrix weeks later.- An absent field declares nothing: neither applied nor reported.
5. The substitution matrix (register row 76, format 1.3).
POST …/batches/{id}/operating-modelacceptssubstitutionEdges[] {sourceRole, targetRole}besideroles[]andpolicies[]. Both ends are resolved through the CONFIRMED role mapping, so an edge naming an undecided role earns the act's existing refusal ("nobody has decided what 'X' becomes here") rather than a refusal of its own.- Each edge is landed by the SHIPPED emergency-cover declaration (ADR-0131 §2's own act:
ACTIVATE_CATALOG_ENTRIESin the scope, the ADR-0131 §3 content-class refusal, oneSUBSTITUTION_EDGE_DECLAREDrow in the break-glass trail) — ADR-0113 D5, so an imported edge is exactly as declared as a typed one. The actor is the importing operator, anddeclared_bysays so. - The 200 body answers
substitutionEdges[] {sourceRole, targetRole, declared, standing, reason}.declaredis true only where THIS call made the judgment, so a replay declares nothing twice and reports it honestly;standingsays whether the edge is in force afterwards;reasoncarries the refusing surface's own sentence for the two per-edge outcomes §8 describes. - An absent or empty array declares nothing and changes nothing — the pre-1.3 behaviour exactly.
Additionally, when the manifest declares them, the target payload carries mayHoldRecords and
mandatorName. Both are declarations for the target to honour or refuse (mandatorName is the
operator-facing check that --mandator-id names the organization the bundle says it belongs to).
16. Writing an exporter: the checklist¶
- Emit
scope-manifest.jsonfirst.formatVersion, the scope block, the fulldocumentTypes[]for every type the corpus uses,idsAuthoritative: true,titleRule: verbatim, the boldlossStatement,sourceSystemandsourceKeyPrefixnaming THIS instance. 1b. State every type'sverificationEvidence, always (1.2), and itsretentionPeriodDays/reviewIntervalDayswherever the catalog holds them. The classification is what makes a released Verification Report evidence rather than a plan; a bundle that leaves it out lands a corpus whose verification status reads one level weaker than it is, and says nothing about it. - Key everything by the human id. Manifest rows, chain keys, sidecar filenames, body filenames, attachment directories.
- State every version's
kindexplicitly. MapIN_REVIEW→DRAFT, skipCANCELLED, emitREVOKEDas itself. Never emitevidence.draftCause: automatic-conversion. - Emit
treeJsonbeside every.md. The stored ProseMirror tree is the record; the stored markdown projection is the body. - Assert completeness before writing the manifest (ADR-0125 G-8): every referenced attachment
has bytes, every version has a
kind. The loader refuses either, but the exporter is where the defect is. - Emit no
source-training.json, and nothing else from the "stays" column of §14. - Emit
operating-model.json— records and vocabulary alone import a project that can be read forever and continued never. 7b. StatesubstitutionEdges, always (1.3),[]included, and emit BOTH ends by role NAME. The matrix is the operating model's fourth member; leaving it out lands a project whose emergency cover silently does not exist. Never emit the source'sdeclaredBy/declaredAt: the receiving operator makes that judgment (§8, §14). - Leave
caveatsempty. Attachments are version-pinned here, unlike the Confluence source. - Do not emit
snapshot/. It is a Confluence-shaped directory and this bundle did not come from Confluence.
Appendix A — What the first real exporter taught the spec (2026-08-26)¶
The reference exporter (PortabilityExportService, commit 4a5ddeaa) surfaced eight points the
sections above understate. The spec text stands; this appendix is the honest delta, folded in
rather than silently patched:
- Two writer-emitted, reader-inert files join §10's list:
README.txt(the loss statement, human-visible, FIRST entry) andmanifest.sha256.txt(a digest of every entry, the PackWriter discipline — the reader ignores it; an auditor does not). - §16 item 1 means authorship order, not zip order:
manifest.jsonis the LAST entry (the completeness marker);scope-manifest.jsonis the second, after the README. treeJsonrides beside every version that HAS a stored tree. A MARKDOWN-format version has none and carries none; §7's derive fallback covers it. §16 item 4's "every .md" is therefore conditional in practice.- Folders travel as document ancestry. The reader derives paths from documents' ancestors, so an EMPTY folder does not survive the trip. §14's "folders travel" carries this caveat.
sourceSystem/sourceKeyPrefixuse the exported scope's CODE as the origin identifier — unique, immutable, the head of every id. No installation-instance identifier exists yet; when one does (the departure-dissolve design is where it first becomes load-bearing), it replaces the code here.evidence.approvedByis emitted name-only. Emitting the email splits one human into two external persons (the persons call mints<name>@<origin>.invalid; the seal would mint the declared address; ADR-0111 keys on(origin, lower(email))). Name-only is also what ADR-0125 §5 / ADR-0108 mean by "real accounts never travel".addresseeRoles: []vs absent — §6 draws a claim-vs-silence distinction the current reader does not: both land as no bindings. The distinction stays in the spec as intent; a reader change would realize it.- Document-target trace links have no slot in a declaring bundle (
requirementLinksis key-target only; the document-target convention edge is Confluence-legacy and OFF for declaring bundles). Cross-scope and document-target references are §6/G-10 territory — the re-binding chapter, still open.
Appendix B — Amendment 2 on the wire (2026-08-27)¶
The bundle format is UNCHANGED (the id namespace is scope-manifest.json's existing code).
The import wire gained: ImportTarget.idNamespace (persisted on the batch; null means the
target's own code) and ImportTarget.adoptForeignNamespace (unpersisted — the human's explicit
declaration that landing ids under a differently-coded scope is deliberate; without it,
divergence refuses). Adoption validates adoptHumanId against the batch's namespace, not the
target's code. The organization declaration is checked only where the target scope has one
(GLOBAL is structurally mandator-less). A GLOBAL target requires an explicit namespace, because
GLOBAL mints unprefixed ids. New preview refusal kind: ID_NAMESPACE. Client: --land-into
CODE sends the pair, prints the deliberate-landing banner, and verifies the batch's namespace
echo before writing anything. An adopted-scope landing cannot be undone through the import's own
dissolve path (that path still refuses to destroy a scope the batch did not create) — inspect at
preview, then finalize.
Appendix C — Format 1.2 and what the rehearsal left standing (2026-09-05)¶
The point release that closes register rows 52 and 53 (ADR-0125 Amendment 4), driven entirely by
the row-37 two-instance rehearsal (docs/planning/row-37-rehearsal-local.md). §1's version history and
the compatibility statement carry the change itself; this appendix is the honest remainder — the
rehearsal's smaller divergences, each either closed here or argued and left, so a later reader is never
left wondering whether an item was missed or decided.
| # | Divergence | Verdict |
|---|---|---|
| D1 | a fully-verified chain arrives PLANNED-ONLY (verification_evidence has no slot) |
CLOSED — 1.2. documentTypes[].verificationEvidence; create applies, adopt reports (§3/§15 item 4). |
| D2 | retention_period / review_interval do not travel |
CLOSED — 1.2. Two more fields, same rule. The number travels faithfully; the target's own /defaults write path owns what it stores for it (register row 67's interval skew is that path's fix, deliberately not worked around here — an imported period must not disagree with a hand-typed one on the same installation). |
| D3 | a dropped cross-scope link counted as zero | CLOSED. The counter keys on the target being outside this scope, by scope id or by target document (§4). |
| D4 | document RELATIONS have no slot and no count | COUNTED, not carried (documentRelationsNotCarried, §4). Carrying them is the same identity re-binding problem as a cross-scope reference — ADR-0125 §6 / gap G-10, explicitly out of 1.x — and a slot without re-binding would land edges pointing at documents the target does not have. What was indefensible was the silence, and that is what is fixed. |
| D5 | DOCUMENT_IMPORTED is written for 12 of 13 documents |
CLOSED as a claim, not as a row. The trail row is written by the seal of a document's first SEALED version, because that is the only act carrying the source's release stamps; a draft-only document arrives as DOCUMENT_CREATED. Writing a second import row from the create path would either double-count or backfill at finalize — inventing an act to make an operator's arithmetic close. The arithmetic is made to close instead: load_arrival's header states the rule and its run report states draftOnlyDocuments, so DOCUMENT_IMPORTED == documents - draftOnlyDocuments is checkable. |
| D6 | "attachments" means three different things | CLOSED by labelling. §4 above states the manifest's unit (distinct FILES) and the loader's summary now says "attachment uploads" (upload ACTS). All three numbers were defensible; none was labelled. |
| D7 | the batch evidence artifact speaks Confluence | CLOSED. attachmentCaveats and training.bindings join evidenceLadder / releaserRule as PER-LINEAGE prose in load_arrival.finalize. An LQMS-origin batch's caveats are [] (§5/§16 item 8 — its attachments are version-pinned) and its training sentence names no Jira custom field. |
| D8 | the origin key is a scope code, not an instance | LEFT, argued. Appendix A item 5 already states it: no installation-instance identifier exists yet, and inventing one here would mint an identity with no owner, no uniqueness guarantee and no lifecycle. It becomes load-bearing in the departure-dissolve design (register row 36), which is where it is sequenced. |
| D9a | element-kind DESCRIPTIONS do not travel | LEFT, argued. Labels and prefixes travel, and those are what a key resolves through; a description is display prose on an activation, carries no meaning a reader derives anything from, and adding a field to elementKinds[] for it would spend a format step on cosmetics. Worth folding into the next real elementKinds change. |
| D9b | the loader's log undercounts element kinds | CLOSED. activated answers true only for what THIS call did, so it was never the scope's total. The line now states both numbers, each labelled. |
| D9c | manifest.json is not covered by manifest.sha256.txt |
LEFT, documented. It cannot be: it is written LAST as the completeness marker and cannot digest itself. §16 item 2 and README.txt say so; an auditor told to "verify the digests" is told which two entries are outside them. |
Appendix D — Format 1.3 and the promise ADR-0131 §2 had made (2026-09-09)¶
The point release that closes register row 76. Its whole content is one array, and what makes it worth an appendix is not the array but the KIND of gap it closed.
The gap was a promise, not an oversight. 1.2's three fields closed divergences the row-37 rehearsal MEASURED — a corpus arriving and saying something subtly wrong about itself. Row 76 is the other shape: ADR-0131 §2 stated, as accepted architecture, that the substitution matrix travels with the operating model, and no code ever made it so. Nothing measured it because nothing looked; the manual lane found it by verifying the guide against the code, and the guide's first fix was to go SILENT about a feature the ADR promised. A published interface whose specification is true of the design and false of the implementation is the worst of the three states — worse than a documented gap, because everybody downstream reasons from the promise.
What travels, restated once: the RULE ("a holder of A may take over B here"), as two role names.
What does not: the act behind it. substitution_edge.declared_by / .declared_at are the source's
witness of a governance judgment, and §14's line holds — the receiving operator declares the edge in
their own name, through the same audited act the emergency-cover surface performs, and the target's trail
says who did it there. That is not a degradation of ADR-0131 §2; it is §2 read literally, because "the
declaration act IS the qualification statement" means the statement cannot be inherited from somebody
else's instance.
| # | Question this step had to answer | Verdict |
|---|---|---|
| E1 | identify roles by name or by id? | NAME. The operating model's own convention: policies name roles, the confirmed mapping table is KEYED by the source name, and no field of this format carries a role UUID. An id would name the exporting installation's catalog and be inert here — and writing an unread field suggests the reader uses it (the same rule that keeps sort_order out of the folder rows). |
| E2 | do an edge's ends have to be confirmed in the role mapping? | YES. They join the set an execute demands be confirmed. Otherwise the matrix would resolve against a decision nobody made — the silent adoption ADR-0114 §2 forbids, arriving by a new door. |
| E3 | what about the source's declaredBy / declaredAt? |
STAY (§14). See above. |
| E4 | an edge the mapping collapses onto ONE instance role | REPORTED, corpus lands. Many-to-one mapping is the normal case (ADR-0114 §2, the PULSEMED five-onto-three), so refusing the operating model over its consequence would make the matrix and many-to-one mapping mutually exclusive. The run names the instance role and the two source roles. |
| E5 | an edge whose target maps onto an ADMINISTRATIVE role here | REPORTED, corpus lands. ADR-0131 §3 refuses it structurally and rightly — the master key through a side door — but the refusal is about THIS installation's catalog, which the exporting instance could not know. The remedy is to re-map, so the run prints the refusal's own sentence (it names the offending permissions) and lands the rest. |
| E6 | an edge naming a role a --limit run does not carry |
DROPPED and NAMED, client-side. The narrowing that keeps a trial run coherent would otherwise leave an edge pointing at a role the operator is never asked about. load_arrival reports it as bundle.substitutionEdgesNotCarried and prints it before the mapping is confirmed. |
| E7 | landing inside the D1 "together or not at all" cage? | NOT YET, argued. The edges are declared AFTER the operating-model transaction commits, because the shipped declaration act opens its own transaction and the roles it needs are created inside that one. The seam a later step should take is a declareEdgeOnTransaction on the emergency-cover service, exactly as activateRoleOnTransaction and setPolicyOnTransaction exist for roles and policies. The residue is bounded and bounded safely: an edge grants nothing by itself (V130's own table comment) — it authorizes a future timed, witnessed, self-expiring act — whereas a missing policy makes a type's documents unsubmittable forever. A landed corpus with an undeclared edge is one governance act from complete; a landed corpus with half its policies is the state D1 exists to prevent. Recorded in docs/planning/row-76-lane-report.md. |
| E8 | prove it by the row, or by the round trip? | ROUND TRIP. PortabilityRoundTripFullStackTest exports, lands through the real load_arrival.py, and re-exports the target, comparing the two substitutionEdges arrays. A one-way assertion would leave the writer's half untested, which is the exact shape of the defect this row closed. |