Decide what motir-core reads once content/legal/ leaves — the legal-document manifest, the unconfigured default, and where the subprocessor guard's evidence lives
Opened by Zhu Yue ·
Type: decision · Executor: coding_agent · Repo: motir-core · ONE PR. The deliverable is an AMENDMENT to docs/decisions/public-surface-hosts.md, cross-referenced from docs/decisions/legal-document-set.md.
content/legal/*.md is not only rendered copy. It is the input to the re-consent gate, and public-surface-hosts.md §7 records MOTIR-3909 as UNCHANGED without noticing that. This card is what makes the story buildable.
What was measured, on motir-core origin/main e76d1afdd (2026-08-30)
| reading | command | result |
|---|---|---|
| callers of the loader outside its own module | git grep -n "listLegalDocuments|getLegalDocument" origin/main -- ':!lib/legal/documents.ts' | app/(public)/legal/{page,[slug]/page}.tsx, app/(auth)/re-consent/page.tsx, lib/services/legalAcceptanceService.ts, plus 6 test files |
content/ top level | git ls-tree origin/main content/ --name-only | content/legal and nothing else |
| the whole referrer population | git grep -l -E "content/legal|lib/legal|'/legal|\"/legal|legalDocument|LegalAcceptance|reconsent" origin/main | 84 files |
legalAcceptanceService.recordAcceptance runs from the Better-Auth user.create.after hook (lib/auth/index.ts:328) on every sign-up; resolveOutstanding runs from resolveReconsentHold, called by the (authed), (onboarding) and (planning) layouts on every signed-in page load. Both call listLegalDocuments(), which is a readdirSync of content/legal/.
So the naive removal is silent. recordAcceptance has an explicit "NO EMPTY-SET GUARD HERE, DELIBERATELY" comment; with no documents it writes zero rows and outstandingReconsent answers []. A cloud deployment would stop recording acceptances and stop holding anyone, with no error and no red test.
Q1 — what does motir-core read instead?
RECOMMENDED: a CONFIGURED MANIFEST. lib/legal/documents.ts keeps its exported surface — listLegalDocuments(), getLegalDocument(slug), legalDocumentSlugs(), LegalDocument — and changes its SOURCE from the filesystem to configuration. Each entry carries slug, title, version, url (absolute, on whatever host the operator publishes), effectiveDate and changeSummary; the body field goes, because the body is what leaves. Unset ⇒ [].
Everything downstream is then unchanged in shape: lib/legal/consent.ts stays pure and keeps RECONSENT_DOCUMENT_SLUGS; legalAcceptanceService keeps its read-at-call-time contract; reconsentGate is untouched.
Rejected, each with its reason:
| alternative | why not |
|---|---|
keep the seven documents in motir-core, gated on MOTIR_CLOUD | public-surface-hosts.md §2 already rejected it: this is CONTENT, and no runtime flag makes another company's privacy policy appropriate in a GPL tree |
motir-core FETCHES the manifest from motir.co at runtime | puts a network hop inside sign-up and inside every signed-in page load, and makes the brand host a hard runtime dependency of the application host. The failure mode is the one resolveReconsentHold already logs around — and it would become routine |
publish the documents as an npm artifact motir-core installs | re-introduces moooon B.V.'s contract text into a package every self-hoster installs. The problem is not where it renders; it is whose it is |
The ADR must also record the shape of the configuration — one env value or a config module — and the VALIDATION, because a malformed manifest is the one input that can make the gate wrong rather than absent: parseSemanticVersion returns null for a version it cannot read and isMaterialChange then answers true, so a typo in the manifest holds every signed-in reader at /re-consent. Decide whether that failure is loud (refuse to boot) or silent (log and treat as unset), and say which.
Q2 — what does an UNCONFIGURED build do?
RECOMMENDED: render no link, and hold nobody. With no manifest, app/(auth)/sign-up's legal sentence re-flows without its two links, the rail's Legal row is absent, and the re-consent rows have no read the document target. A link to a document the operator has not published is a 404 inside a legal notice, which is worse than no link.
And the gate stays MOTIR_CLOUD-gated — DECIDED here, not deferred. isMotirCloud()'s own comment says it answers "is moooon B.V. the counterparty to these documents?", and a configured manifest does not answer that question. An operator publishing their own terms does not thereby acquire our re-consent semantics. Widening it is a product decision this record explicitly does not make and does not owe a follow-up card, because nothing in this story depends on it.
Q3 — where does the subprocessor guard's evidence live?
tests/legal/subprocessor-list-guard.test.ts holds content/legal/subprocessors.md's rows against motir-core's own package.json dependencies and outbound hosts (tests/helpers/subprocessorRegistry.ts's VENDOR_SIGNATURES). The page moves; the evidence cannot. Deleting the guard is not available — its own header records that the page went stale four times on 2026-08-26/27.
RECOMMENDED: split it at the repository line, and make the seam fail on divergence. motir-core keeps the measurement — the signatures, run against its own tree — and emits the result as a committed, guarded egress manifest (a fact about the open-source software's outbound dependencies, which genuinely belongs in motir-core). motir-marketing's /legal build asserts every vendor row on its subprocessors.md has a matching entry in that manifest.
The ADR decides WHICH TRANSPORT carries the manifest across, and it is the same question MOTIR-3932 raises for /docs — a published artifact that does not rot, versus a copy that does. State the coupling: if this record and MOTIR-3932 pick differently, one of them is wrong. The requirement this record imposes is not the transport but the property: the seam must FAIL when the two sides diverge, and a copy with no drift check does not satisfy it.
FIRST STEP — the rung-1 check, which this card owes and must not assert from memory
Before writing the recommendation, check how the mirror open-core products actually do this: read GitLab's, Sentry's, Mattermost's and Plane's open repositories for whether the vendor's own Terms / Privacy / subprocessor text ships in the tree, and what a self-hosted install links to instead. Cite what you read, with URLs, in the ADR's own evidence section. A remembered claim is not a check (plan-rules/core.md, rung 1) — and if the mirrors turn out to keep their legal text in-tree, that is an argument this record has to answer rather than omit.
Boundary
- It writes no application code. Every mechanism it decides is built by a named sibling.
- It does not decide the
/legalpage's layout — the motir.co design card does. - It does not decide the 301 — MOTIR-3884 already owns
app.motir.co/{explore,docs,legal,p}/*. - It does not re-open the move itself.
public-surface-hosts.md§2 decided that/legalleaves; this amends how, not whether.
Acceptance criteria
docs/decisions/public-surface-hosts.mdcarries a dated AMENDMENT answering Q1, Q2 and Q3, each with its rejected alternatives and the reason, in the record's existing table style.- The amendment quotes the measurement above — the loader's non-test callers and the two runtime entry points — as the reason §7's "MOTIR-3909 UNCHANGED" is superseded, and edits that §7 row to say so.
- The manifest's field set and its validation behaviour are written down, including what a build does with a manifest whose
versiondoes not parse, and why. - The unconfigured behaviour is stated per surface — sign-up, the rail row, the re-consent rows, the gate — with the reason a link is omitted rather than pointed at a default.
- The
MOTIR_CLOUDgating of re-consent is stated as retained, with its reason, so a later reader does not read the manifest as having widened it. - Q3 names the transport for the egress manifest, states the coupling to MOTIR-3932's
/docsmechanism, and requires the seam to fail on divergence. - The rung-1 reading of at least three open-core mirrors is cited with URLs in the record's evidence section.
docs/decisions/legal-document-set.mdgains a one-line pointer to the amendment, since it owns the document set this changes the home of.
Context refs
motir-core/docs/decisions/public-surface-hosts.md— the record being amended; §2's host table, §5's self-host table and §7's card listmotir-core/docs/decisions/legal-document-set.md— the record that owns the seven-document setmotir-core/lib/legal/documents.ts— the loader whose source changesmotir-core/lib/legal/consent.ts—RECONSENT_DOCUMENT_SLUGSand the materiality rule, both unaffectedmotir-core/lib/legal/reconsentGate.ts—isMotirCloud()and its recorded reasonmotir-core/lib/services/legalAcceptanceService.ts·motir-core/lib/auth/index.ts— the two runtime entry pointsmotir-core/tests/helpers/subprocessorRegistry.ts·motir-core/tests/legal/subprocessor-list-guard.test.ts— Q3's subject- MOTIR-3932 — the story raising the same cross-repo transport question for
/docs
Advisory dispositions
likely-missing-edge→ MOTIR-3932: NOT REAL, and no edge is owed. Q3 names that story to record a coupling — if the two records pick different transports for the same kind of cross-repo artifact, one of them is wrong — not to consume anything it produces. This card's own requirement is a PROPERTY (the seam fails on divergence), dischargeable whichever transport either record picks and whichever lands first. An edge would also be cross-parent and would make one ADR wait on an entire sibling story.- 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.
Discussion
No comments yet.
Adding to this discussion signs you in on app.motir.co and brings you back to this request.