lib/legal/documents.ts becomes a CONFIGURED manifest reader — the re-consent gate keeps working with no content/legal/ in the tree
Type: code · Executor: coding_agent · Repo: motir-core · ONE PR.
lib/legal/documents.ts is the ONE place that reads content/legal/, and it feeds two things: the pages that are leaving, and the re-consent gate that is staying. This card changes its SOURCE from the filesystem to the configured manifest the decision card settles, so the gate keeps working with nothing in content/.
What the module does today, verified on origin/main e76d1afdd
LEGAL_DIR = join(process.cwd(), 'content', 'legal'); listLegalDocuments() is a readdirSync + one readFileSync per .md; parseLegalDocument splits front matter into { slug, title, version, effectiveDate, status, changeSummary, body }, mapping TBD and '' to a null effectiveDate; getLegalDocument matches against the DIRECTORY LISTING rather than building a path, which is what makes ../../etc/passwd return null; byPreferredOrder sorts by PREFERRED_ORDER then slug; legalDocumentSlugs maps the list.
⚠️ AMENDED 2026-09-01 by MOTIR-4004 —
PREFERRED_ORDERANDbyPreferredOrderARE REMOVED, and the manifest's own array ORDER is authoritative.docs/decisions/public-surface-hosts.mdAMENDMENT 2 §C decided it: the constant existed because a directory listing has no order, an authored array does, and a hardcoded list in the open product re-sorting an operator's manifest imposes moooon's document ordering on every self-hoster — a smaller instance of exactly what this story is undoing. So the criterion below listingbyPreferredOrderamong the unchanged exports is superseded: it goes, with its constant. Nothing else about the exported surface changes, and no consumer imports it (git grep -n byPreferredOrder— its only non-test reader islistLegalDocumentsitself).The manifest's SHAPE and its VALIDATION are also decided there and are not open questions for this card: one env value,
MOTIR_LEGAL_DOCUMENTS, holding a JSON array of{ slug, title, version, effectiveDate, changeSummary, url }; an entry that does not validate is rejected per entry AND loudly — an error log naming the slug and the failing field, a health/preflight surface reporting the manifest as faulted rather than unconfigured, and a separately named condition when the rejected slug is one ofRECONSENT_DOCUMENT_SLUGS. Silent rejection is explicitly rejected there.
Its non-test callers, and what each takes — git grep -n "listLegalDocuments\|getLegalDocument" origin/main -- ':!lib/legal/documents.ts':
| caller | fields used | disposition |
|---|---|---|
lib/services/legalAcceptanceService.ts:34 | slug, version | stays — re-pointed at the manifest by this card |
app/(auth)/re-consent/page.tsx:63 | slug, title, version only, and it already handles null | stays, unchanged in shape |
app/(public)/legal/page.tsx · [slug]/page.tsx | everything including body | leaves — the deletion card's |
No surviving caller reads body. That is what makes this a source swap rather than a redesign.
The change
lib/legal/documents.tsreads the manifest. Keep every export —LegalDocument,listLegalDocuments,getLegalDocument,legalDocumentSlugs,byPreferredOrder— so no consumer's import changes.LegalDocumentgainsurland losesbody;parseLegalDocumentandsplitFrontMattergo with the files they parsed.- Parse and VALIDATE the manifest once, per the ADR's chosen shape and its chosen failure behaviour. This is the load-bearing half, because a malformed entry does not degrade to absent:
parseSemanticVersionreturnsnullfor an unreadable version andisMaterialChangethen answers true, which holds every signed-in reader at/re-consent. An entry that does not validate must not reachlistLegalDocuments(). - Unset ⇒
[]. Every downstream behaviour for the empty list already exists and is documented:recordAcceptance's "NO EMPTY-SET GUARD HERE, DELIBERATELY",outstandingReconsent's[], andre-consent/page.tsx'sterms ? … : null. Do not add a second guard at the service tier — that comment says why. lib/legal/consent.tsis UNCHANGED. It is pure, it takes documents as an argument, andRECONSENT_DOCUMENT_SLUGSstays the closed three-slug list for the reason its own header gives. Adding a document to the manifest must still not silently start gating the product on it.lib/services/legalAcceptanceService.tskeeps its read-at-call-time contract. Its comment argues against a module-level cache because a cache serves the previous version of the Terms for the life of a process; a manifest read is cheaper than thereaddirSyncit replaces, so the argument holds and the shape does not change.PREFERRED_ORDER's disposition is decided and stated. Either the manifest's own order is authoritative (drop the constant) or the constant still sorts it (keep it, and say why a manifest author's order is not trusted). Do not leave both.
⚠️ The coverage floors are 100% and they are in the config already
vitest.config.ts pins per-file thresholds on lib/legal/consent.ts, lib/legal/documents.ts, lib/legal/reconsentGate.ts, lib/repositories/legalAcceptanceRepository.ts and lib/services/legalAcceptanceService.ts. Rewriting documents.ts means its new branches — a missing manifest, a malformed entry, an unknown slug, an entry with no effective date — are all covered in this PR. byPreferredOrder is exported and pure precisely so its unknown-slug branches are reachable from a unit test; keep that property whatever the constant's disposition.
Boundary
- It changes no rendered surface. Sign-up, the rail and the re-consent rows are the link-surfaces card's; this card's
urlfield is what that one consumes. - It deletes nothing.
content/legal/andapp/(public)/legal/are still in the tree when this merges — the pages simply stop being the loader's reason to exist. The deletion card removes them. - It does not touch
lib/legal/consent.tsorreconsentGate.ts. - It does not provision anything. The cloud deployment's manifest value is the provisioning card's.
Acceptance criteria
lib/legal/documents.tsreads the configured manifest and touches no filesystem;git grep -n "content/legal\|readdirSync\|LEGAL_DIR" lib/legal/documents.tsreturns nothing.- Its exported surface is unchanged in NAME and call shape —
listLegalDocuments,getLegalDocument,legalDocumentSlugs,byPreferredOrder,LegalDocument— so no consumer's import line changes;LegalDocumentcarriesurland no longer carriesbody. - With no manifest configured
listLegalDocuments()returns[],getLegalDocument(<any>)returnsnull, and a unit test asserts each. - A malformed entry never reaches a consumer: a test drives an entry whose
versiondoes not match^\d+\.\d+\.\d+$and asserts the ADR's chosen behaviour, naming the alternative that was rejected. getLegalDocumentstill returnsnullfor a slug that names no entry, including a traversal-shaped one — the existing'../../package'and'../../../etc/passwd'cases intests/legal/legalDocuments.test.tsstay green.legalAcceptanceService.recordAcceptanceandresolveOutstandingbehave identically over a manifest of the same three documents as over today's files — asserted against real Postgres, not a stub.- No second empty-set guard is added at the service tier, and the PR body quotes the comment that says why.
PREFERRED_ORDER's disposition is stated in the module and in the PR body.- The five files
vitest.config.tspins are at their configured thresholds; the PR body shows the coverage run. - No test is deleted because its subject moved: a test covering front-matter PARSING moves with the parser to the motir.co card or is removed with its reason stated in the PR body.
Context refs
motir-core/lib/legal/documents.ts— the module being rewrittenmotir-core/lib/legal/consent.ts— unchanged; read itsRECONSENT_DOCUMENT_SLUGSheader for why the list stays closedmotir-core/lib/services/legalAcceptanceService.ts—reconsentDocuments()and the no-cache argumentmotir-core/app/(auth)/re-consent/page.tsx— the second surviving caller; takesslug/title/versionand already handlesnullmotir-core/vitest.config.ts— the per-file coverage thresholds on the five legal filesmotir-core/tests/legal/legalDocuments.test.ts— the traversal and ordering cases that must survivemotir-core/CLAUDE.md— the layering contract- the decision card — the manifest shape and the validation behaviour;
blocked_by
Advisory dispositions
likely-missing-edge→ the motir.co card: NOT REAL, and the edge would INVERT the order. This card names that one as the recipient of the front-matter parser it retires, not as a producer it reads. Nothing here consumes anything that card ships; the hand-off runs the other way, and that card isblocked_byits own design rather than by this one.- The remaining
referenceadvisories name cards this one's Boundary excludes. A boundary exists to say a deliverable is somebody else's, and naming the owner is what makes the exclusion checkable — so the reference is deliberate and no edge is owed.