ADR-0118: The Offline QMS Copy — the Repo Carries the Audit Pack, Not a Second Renderer¶
- Status: accepted — in scope for v1.0.
Original status line (2026-08-21), kept as the trail: proposed — accepted in substance on format, source and target: "an offline pointer in the repo is a must have… so we can show the documents at any time", then "about the export pdf is even better than markdown as it is immutable", then "draft it, target v1.0". The eligibility rule — which scopes may be mirrored — is open decision 10 and is the one part of this ADR not yet ruled on.
Status note (2026-09-04): the gate this ADR cited as open had already been ruled. Open decision 10 was DECIDED 2026-08-24 — "mirror eligibility is the internal-QMS scope only (ADR-0118 implementation unblocked, pack measurement first)" — which is exactly §6 as written, so §6's "(open decision 10; proposed)" now reads as decided. The ADR nonetheless satproposedfor eleven days citing the ruled decision, and the index cell repeated the stale gate (found by the 2026-09-04 ADR-coherence review, U-4). Status flipped to accepted on his same-day confirmation of the 1.0 question that was genuinely still open: "I think the offline copy should be in 1.0 unless you think we may postpone it" → IN 1.0, confirmed (register row 9; it is a script and a CI check, not a slice, and an offline QMS copy is directly part of the November audit story). The one remaining precondition is unchanged and is not a decision: the pack-size measurement named in Consequences. Tracked as v1.0-criteria.md C-4d.
Status note (2026-09-06): that precondition is discharged (≈1.8 MB per mirror generation — decision 2 stands) and the mechanism is built: see Amendment 1 below for what was implemented, the five places §3's sketch was corrected against the real pack, and the one half of the GLOBAL-boundary question that is still open for ruling. What C-4d has left is not code: the first refresh must be run against the instance that holds the internal-QMS scope, and its output committed. - Date: 2026-08-21
- Deciders: Nicolas Burri
- Relates to: ADR-0074 (single-document PDF export — the §4.2.4 identity header and uncontrolled-copy footer this reuses wholesale), ADR-0083 D-1/D-4 (the scope audit pack and its sha256 manifest — the artifact this commits), ADR-0111 route 4 (the portable project export — explicitly a different job, see Context), ADR-0116 (the maintenance surface that already treats an export as an audited act), risk_management.md D-3 (the acceptance records this mechanism is what makes reachable offline).
Context¶
D-3 put each risk-acceptance decision inside LQMS as a released record, and the same reasoning
applies to the risk-management SOP and eventually to the engineering risk register as risk /
risk-control trace items (ADR-0079). That is the right home: it gives an approver, a date, an
immutable trail, a review cadence, and traceability the tool can walk.
It also creates a bootstrap problem, stated plainly rather than discovered later: the QMS documentation of a tool would live only inside a running instance of that tool. A developer with a git clone, an auditor with no account, or anyone at all during an outage would have no way to read the records — including the records that describe how the tool manages its own risk.
The first instinct was a Markdown mirror, since document bodies are already Markdown with :::
directive fences. Nicolas rejected that in favour of PDF, and the reason survives scrutiny better
than the one he gave: PDF is not immutable — it is as editable as anything else. What makes it
evidence-grade is three properties the PDF path already has and a Markdown path would have to
reinvent:
- the ISO 13485 §4.2.4 identity block (document id, version, status, dates) rendered into the page;
- the §4.2.4 distribution-control footer band on every page — "uncontrolled copy, issued …" — which is precisely the marking a copy outside the controlled system is required to carry;
- a SHA-256 per file in
manifest.json, so tampering is detectable even though it is not prevented.
A third property matters as much: the pack is already built. GET /api/scopes/{scopeId}/audit-pack
streams the currently-effective RELEASED version of every document the caller can see, plus the
transitive citation closure, writes the completeness-marker manifest last, and records the
EXPORTED audit event only after the final byte — so a truncated bundle is detectable rather than
silently short. There is nothing to build but a script.
This is not the export/portability twin. ADR-0111 route 4 moves a corpus between instances and must round-trip. This moves readable released content plus provenance into a git repository and never round-trips. Conflating them was an early error in this design: it made the offline copy look like it had to wait for the twin, when in fact it can ship first and independently.
Decision¶
-
The offline copy is the scope audit pack, committed to the repository as PDFs. No Markdown emitter, no second renderer, no second parse of the fence grammar. One renderer means the repo copy and the download an auditor receives are the same artifact, byte-for-byte reproducible from the same code path.
-
Loose PDFs, not the zip. GitHub renders a PDF in the browser and cannot render one inside a zip; "show the documents at any time" is the requirement, and a copy that must be downloaded and unpacked first does not meet it.
manifest.jsonis committed alongside, unpacked, so the digests are greppable and diffable even though the PDFs are not. -
Layout — generated, never hand-edited:
docs/qms/
README.md # what this is; the uncontrolled-copy notice; how to refresh
index.md # key | title | version | released | link — the pointer that survives alone
<scopeCode>_<humanId>_v<n>.pdf
manifest.json # instance, export timestamp, per-document version + sha256
index.md is the load-bearing file. If every PDF were somehow lost, the existence and identity
of each controlled record still survives in git — which is the minimum the technical file needs.
-
One writer:
scripts/refresh-qms-mirror.py. It pulls the pack, unpacks it, regeneratesindex.mdfrommanifest.json, and writes nothing else. Determinism is a requirement, not a nicety: stable ordering, and no timestamp anywhere exceptmanifest.json, or every refresh produces noise diffs. -
Drift is two questions, and they get different answers.
- Is the committed set internally consistent? — offline, mechanical, cheap:
index.mdmatchesmanifest.jsonmatches the files present, and every PDF's sha256 matches its manifest entry. This runs on every PR and blocks. - Is it still the latest released version? — needs the instance, so it runs at release time and nightly, and is advisory when the instance is unreachable.
Conflating them either couples the build to a running server or lets a stale SOP sit in git looking authoritative.
- Eligibility is opt-in and narrow (open decision 10 — DECIDED 2026-08-24 exactly as proposed here): the dedicated internal-QMS scope only. The repository becomes a distribution channel the day this lands, git history is not being scrubbed by standing ruling, and a mirrored scope that should not have been mirrored is therefore permanent. A per-scope allowlist checked by the refresh script — not a sweep, not a per-document flag that a future UI could toggle by accident.
Consequences¶
- The bootstrap problem is solved without weakening D-3. Acceptance records stay controlled records inside LQMS; the repo carries a marked, digest-pinned, uncontrolled copy. Both statements are true at once, which is exactly what §4.2.4 contemplates.
- The repo gains binary files that never diff.
git diffon a changed SOP says "binary files differ". Answering what changed requires LQMS, or comparing the two manifests' digests to see that it changed. Accepted deliberately: the reader who needs the diff has the tool; the reader who needs the document does not. - Permanent weight. Each refresh adds a full copy of each changed PDF to history. Unmeasured as of this writing — the sizing check (a real pack, rendered) is a precondition for implementation, not a detail to discover afterwards. If a refresh turns out to cost more than a few MB, revisit decision 2 in favour of the zip and accept the loss of in-browser rendering.
- A new honesty obligation.
docs/qms/README.mdmust say, without hedging, that these are uncontrolled copies, that the controlled records are in LQMS, and how to verify a digest. A copy that does not say it is a copy is worse than no copy. - It is the strongest available demonstration of the product. "Here is our own QMS, in our own tool, with its released records mirrored and digest-verified" answers why should I trust this better than any feature list.
Alternatives considered¶
- Markdown mirror (rejected). Diffable and readable in GitHub, and nearly free to emit from
stored content — but the
:::fences render as literal noise unless a projecting emitter is written, which is a second renderer that can drift from the PDF, and it reinvents the identity header, the uncontrolled-copy marking and the digest chain. Reconsider only if diffability becomes a stated requirement. - Wait for the export/portability twin (rejected). Based on the mistaken premise that the twin was the source. It is not; the audit pack is. Waiting would have delayed the offline copy behind an unrelated and much larger slice.
- Serve it from docs.lqms.ch instead (rejected as a substitute, retained as a companion). A website is not available in a clone, on a plane, or during the outage that is the whole reason the offline copy exists. The same refresh output can feed the site later; the site cannot replace it.
- Commit nothing; document how to pull a pack (rejected). This is what exists today, and it fails the actual requirement: a procedure only reachable by someone with an account and a running instance is not an offline copy.
Amendment 1 (2026-09-06) — built; what the pack actually is, and where §3's sketch was wrong¶
The mechanism is implemented (C-4d): scripts/refresh-qms-mirror.py (the one writer),
scripts/check-qms-mirror.py (§5's two questions), scripts/qms-mirror.json (§6's allowlist), the
consistency check riding CI's existing pytest step, and .github/workflows/qms-mirror.yml for the
one PR shape that step cannot see. Building it against the real exportAuditPackStreaming output
surfaced five points where this ADR described something slightly other than what the server writes.
Each is corrected in favour of the ADR's own governing principle — one renderer, one artifact, no
second anything — rather than in favour of the sketch.
-
The precondition is met: the sizing measurement is DONE, and decision 2 stands. Rendered through the real ADR-0074 path, the 17KB table-heavy, image-bearing SOP fixture produces a 46,665-byte PDF (32,799 without the inlined diagram). A ~40-document mirror generation is therefore ≈1.8 MB — comfortably inside "a few MB", so loose PDFs stay, and the zip alternative is not revisited. Pinned as a standing measurement in
PdfExportServiceTest."SIZE probe - the offline mirror's committed weight per refresh", with a 20 MB ceiling that fires only if a renderer change makes the git-history cost qualitatively different from the one decision 2 was taken under. The Consequences' "Unmeasured as of this writing" is discharged. -
§3's filename sketch
<scopeCode>_<humanId>_v<n>.pdfis superseded by the pack's own entry names.PackWriter.packEntryNamewrites<humanId>_v<n> — <title>.pdf(em dash included), scope-prefixed only to break a collision. The sketch is wrong twice over: the human id already carries the scope code (QMS-OPS-SOP-001), so the prefix would double it — and, decisively, a renaming writer would break decision 1.manifest.json'sfilefield and the in-pack cross-documentdoc:links both resolve through that exact name, so renaming on the way into git would split the manifest from the files and turn every cross-document link dead. The mirror unpacks verbatim. -
README.txtandmanifest.txtare not committed, andmanifest.txtearns its keep on the way past. §3's tree lists neither.manifest.txtis the pack's completeness marker — the LAST entry the server writes — so the writer requires it and refuses a pack without it as truncated, then drops it: its facts are inmanifest.json, andindex.mdsays them in the repo's voice. The pack'sREADME.txtis likewise dropped in favour of the generateddocs/qms/README.mdthe honesty obligation names. -
manifest.jsonis committed pretty-printed, not byte-verbatim. The server writes it as one line. Decision 2 commits it unpacked specifically so "the digests are greppable and diffable", which a single line defeats — one entry per line is what makesgit diffon a refresh say WHICH document changed. Same content, same key order, deterministic. (Decision 1's byte-for-byte claim is about the PDFs, which are untouched.) -
traceability-report.pdfrides when the pack carries it (ADR-0087, permission-conditional). It is digest-checked like every other file, and indexed under its own heading inindex.md— never in the controlled-record table, because it is a derived point-in-time overview with no version and no release date.
The GLOBAL boundary — the call made, and the half still open¶
samd-stack-outline §4.5 asks whether the GLOBAL base library, now carrying the stack corpus, is inside the mirror boundary. Half of that is already settled by ruled decisions and is now implemented; half needs Nicolas.
- Settled and built: the audit pack's citation closure deliberately spans into the base library
(ADR-0083 D-1 amendment; register row 45 ruled it kept, and made its entries say so via
baseLibrary: true). A base-library document cited by an internal-QMS record is therefore inside the boundary, mirrored and labelled as such inindex.md. That follows from adopting the pack wholesale; no new ruling was needed. - Enforced, and this is the part §6 needed to be true rather than intended: everything else
foreign is refused. A cited document from an ordinary project scope would publish that scope
permanently — the exact harm §6 names — so the writer aborts on it rather than committing it.
Configuration, not a constant:
referencedOriginsinscripts/qms-mirror.json(self+base-library, the shipped default |any, implemented and deliberately unshipped). - STILL OPEN, for Nicolas: whether the base library's whole released corpus — the stack
documents nothing in the internal QMS happens to cite — should be mirrored as a second pack. It
needs a layout ruling as well as an eligibility one: §3's tree has ONE
manifest.json, so two packs cannot share it without a subdirectory scheme this ADR does not rule.load_configrefuses a second configured scope with that reason named, rather than inventing a layout.