Descent Docs — System Architecture Whitepaper
Descent Docs — System Architecture Whitepaper
Date: 2026-08-08 · Bounded context: the documentation platform (
apps/descent-docsplus this directory). Authority: this document is authoritative for the platform’s own architecture and for nothing else — the VTT corpus indocs/vtt/remains the single source for every architectural claim it makes, and this platform is a renderer of that corpus, never a second home for it.Decision governance, stated up front: this context deliberately has no ADR namespace of its own. Its decisions live in the main
R-sequence (ADR-112 is the founding one), because the platform is tooling for the corpus and splitting its decisions from the corpus’s would put the two halves of one argument in two filing systems. This is the opposite call from the Marketplace’sADR-M, and it is deliberate: the Marketplace is a separate product with a separate deployment; this platform’s entire subject matter is the corpus itself.
1. What this platform is
A static rendering of the architecture corpus with three capabilities GitHub’s renderer cannot provide: search (Pagefind), live citations (the Q-ID Registry Resolver and ADR status links), and a fail-closed disclosure filter between the repository’s schemas and anything the platform publishes.
The platform’s standing rule, from which everything below derives: it cites the corpus and
never copies it. A copy that drifts is this repository’s most-recorded defect class (the
W6 incident is the named example), so every mechanism here resolves against the
authoritative file at build time — the registry, the §11 index, the schemas — and embeds no
second copy of any of them.
2. Platform architecture (ADR-112)
Astro + Starlight, static output only, zero JS by default; Pagefind arrives as Starlight’s own dependency. ADR-112 carries the selection argument, the alternatives, and the exit cost; this document does not restate them. Two of its clauses shape everything in this file:
astro.config.mjsis wiring only. Custom logic lives in framework-agnostic unified plugins undertools/, consumable by any unified pipeline. The practical test applied to each component below: could Docusaurus mount it tomorrow? For the resolver and the scrubber the answer must stay yes; for the thin.astropresentation shims it is deliberately no, and they are kept too small to matter.- The exit cost’s sharpest component is the scrubbing pipeline, because it is a security
control coupled to a build system. That is why the scrubber is a standalone CLI first
(
tools/docs-schema-scrubber.mjs) and a build step second — retiring Astro would change what invokes it, not what it is.
3. Doc-as-Code dynamic resolution
3.1 The Q-ID Registry Resolver
A unified remark plugin (tools/remark-qid-resolver.mjs). [[Q-001]] in a docs page
renders the live value from docs/vtt/Quantity_Registry.md, with the id, name and status in
the title. The registry parse handles both real row shapes — the five-cell value tables and
the four-cell Pending sections — and a Pending quantity renders as pending, because
inventing a number for a row whose deliverable is its own existence would falsify the
registry’s most careful distinction.
The enforcement point is the build itself, in both directions (P4): an unknown or
retired citation throws, and a registry that parses to zero rows throws — a resolver
resolving against nothing approves everything, which is the vacuity failure every checker in
tools/ guards against. The plugin’s behaviour is pinned by tools/remark-qid-resolver.test.mjs,
which runs under the frontend Vitest with every other tools check.
3.2 ADR status links
<AdrLink id="111" /> renders ADR-111 · Accepted; a retired decision renders struck
through with its successor visible (ADR-018 · Superseded-by 033). Status comes from the
whitepaper’s §11 index at build time via tools/adr-index.mjs — the index rather than
the sixty record files, because docs/README.md is explicit that §11 is the index and the
R-files are the records, and because adr_link_lint already holds that one table’s shape
stable. An unknown id fails the build, same argument as §3.1.
The .astro component itself is presentation over the parser and carries no logic worth
porting — which is the §2 division of labour applied.
3.3 Cross-document invariants (ADR-178)
The platform reads every published file, and until 2026-08-13 it enforced almost none of the
corpus’s cross-document invariants. The five Python lints read the whitepaper;
doc_reference_lint.py is the exception and resolves references inside docs/. Meanwhile
the corpus’s most-recorded defect class is two documents that agreed when written and silently
diverged — a shape no single-file checker can see, because neither document is wrong on its own.
tools/docs-corpus-invariants.mjs is a sixth lint and the first that reads more than one file
at a time. Six checks, each one a drift this corpus has recorded catching by hand: the
record-location table against the directory listing; the ADR row count against §11; every ADR
id cited anywhere in the repository against the index; the “On code” paragraph’s date against
the newest decision record; a spike recorded closed and still named as a blocker; and a
repository path cited from outside docs/, which is doc_reference_lint.py’s structural blind
spot. §3.1’s parser pattern is what it follows — a framework-agnostic module a build step
consumes — and §3.2’s tools/adr-index.mjs is what it reads §11 through, so the two cannot
disagree about what a row is.
It is a ratchet, not a wall, and the ledger is two-sided: an undeclared finding fails the
build, and a declared line that no longer fires fails it too. That is the shape
enforcement_baseline.txt and uncited_ceiling.txt already use, for the reason both records
give — a checker that only catches growth records a debt nobody can watch being paid off.
The enforcement point is docs-lint.yml, not this platform’s own workflow, and the reason
is worth stating because it contradicts §2’s placement rule at first reading. It does not:
§2 governs where the logic lives, and the logic is a portable module under tools/. What
docs-platform-ci.yml cannot do is fire — its path filter is narrowed for Actions-budget
reasons and does not include apps/** or core/**, so a citation of a non-existent ADR
appearing in a .cs file would never reach it. That is precisely the change that produced the
133 gap. docs-lint.yml has no path filter.
Stated rather than left to be found: the check’s mutation suite runs under the frontend
Vitest with every other tools/ test, which is a different workflow from the one that runs
the gate — weaker than the Python lints, whose self-tests are the first step of the same job.
--self-check closes the dangerous half of that gap in this job by proving every checker
refuses an empty input; it does not prove the checkers are right.
4. The schema scrubbing pipeline
4.1 What the property actually is — and what it is not
The mission-level name for this pipeline is “zero-knowledge”, and that term is not used normatively here, because it has a precise cryptographic meaning this pipeline does not have and does not need. The property this pipeline provides, stated exactly:
Published schema pages contain only files on an explicit allowlist, with every internal-marked region removed; the count of withheld lines is public, the content is not; and any marking the grammar does not recognise refuses the build rather than passing through.
This is a fail-closed disclosure filter. It matters because ADR-111 closed the core
engines while the wire schemas’ commentary cites their internals — path.rs behaviour, the
crate’s answer caps, the mask-stripping location — and a docs platform that republished
those comments verbatim would undo a licensing decision with a documentation build.
4.2 Mechanism
tools/docs-schema-scrubber.mjs, a dependency-free CLI:
- An allowlist, never a glob (
PUBLISH_MANIFEST). A schema not listed does not exist to this pipeline. The sandbox suite’s own recorded incident is the argument: a denylist scan passed a leaked connection string because a secret does not look like the things you thought to list. - A strict marking grammar. The marker token is the at-sign glyph immediately
followed by the word
internal, and this document describes it rather than spelling it: this page renders through the pipeline it specifies, the belt below scans every shipped byte, and the owner ruled the belt absolute with no prose exemption (2026-08-08, R20 clause 3) — the literal form lives only in the scrubber’s source and tests. Writing@…for the token, the grammar’s forms are: a whole-line// @…marker, and// @…:begin/// @…:endregions, in plain//comments only. A///doc-comment marker is refused, not honoured — flatc carries///into generated code, so a doc-comment marker is a marker that travels. Unterminated, stray, or nested markers refuse. - The belt: if the token survives into any output in any form, the scrub fails. An unrecognised marking is treated as marking, never as prose.
- Honest pages: each published page states its source path, that it is generated, and its withheld-line count — including “Nothing was withheld” where that is the truth.
Output is generated into the docs app on every build and never committed — the schema stays the single source, the same rule the wire codegen follows (§9.6 of the VTT whitepaper).
4.3 Enforcement points (ADR-045)
| Property | Enforcement point |
|---|---|
| The grammar, the refusals, the allowlist scope | tools/docs-schema-scrubber.test.mjs — runs under the frontend Vitest on every frontend CI pass; the suite was mutation-tested (a block-content leak and a disabled belt were both killed) |
| Nothing marked reaches the shipped site | docs-platform-ci.yml runs --check over the built dist/ after every build — the assertion is on the artefact, which is what ADR-112 demanded of this gate |
| The manifest stays inside the wire-schema directory | a test refuses any manifest entry outside modules/Descent.Vtt.Protocol/schemas/ — widening the pipeline to core/ is a deliberate edit to both the manifest and that test, with the argument |
| Generated output is never committed | apps/descent-docs/.gitignore |
4.4 Scope, stated plainly
The pipeline currently publishes three wire schemas. It does not publish anything from
core/ (the test in §4.3 refuses it), does not process C# interfaces, and does not claim to
sanitise prose in docs/ — corpus publication was ruled 2026-08-08: ADR-113 authorises an
allowlisted, in-place, audience-conditional rendering, with
Corpus_Publication_Disclosure_Review.md as its derivation record. The no-sanitisation
position is unchanged — prose publishes whole-file by allowlist, never scrubbed region-wise
(ADR-113 clause 5) — and this file is its own worked example: its first rendered build
tripped the --check belt because §4.2 then spelled the marker token literally. The owner
ruled the belt absolute — no per-file exemption — so this document now describes the token
without spelling it and renders like the rest of the corpus (the dated notes in R20
clause 3).
5. What is not built, so this document cannot be read as claiming it
No deployment target— ruled 2026-08-08 (ADR-114): the site deploys to Cloudflare Pages behind Cloudflare Access with GitHub as the identity provider, so the audience equals repository collaborators by IdP predicate and ADR-113 clause 6 is discharged for exactly that audience while staying in force for any wider one.siteis set (the same ruling’s clause 6) and the sitemap ships behind the same boundary. The deploy is CI-owned, armed by a provisioning act on the release checklist, and ends with a smoke assertion that an unauthenticated request never receives content. Still not deployed as of 2026-08-13, so that assertion has never run. The provisioning isRC-D-01indocs/Release_Checklist.md— a file that did not exist until R44 created it, although thirty sentences in this corpus name “the release checklist” as their enforcement point (F-R44-02).RC-D-02beside it argues the §10.1.1 exception the hand-provisioning takes and gives it an expiry. The assertion was widened from one URL to seven by ADR-178 clause 6. Pagefind builds a static index at build time over what the build rendered, so the index carries the prose of every published page: an index is content, and a boundary proved on a page and not on/pagefind/is proved on a neighbour. The scrubbed schema pages, the assets published besideui_ux_mockups.md, and the sitemap are the other classes now probed. Confirmed by reading the builtdist/rather than assumed: the index and the twelve images are there, and each is as reachable as any page.No corpus ingestion— landed 2026-08-08 (ADR-113): the allowlisted corpus renders in place throughtools/docs-corpus-loader.mjsagainsttools/docs-publication-manifest.mjs, gated in CI by the two-sided route check, the token belt over the built site, and the read-only witness overdocs/. The template placeholder content is gone.- No Studio-modder distribution channel. ADR-111 confines third-party integration to Studio plugins; when a modder-facing docs channel exists, its access model is decided then, not implied now.