Skip to content

RTOpacks Architecture Decisions

Status: Canonical log of architectural commitments. Governance level: Position 4 in the standing-rules.md governance hierarchy. Below the Client Spine (client-spine.md), standing rules, and WS-PRODUCT-01. Above briefs and module specs. Format: Architecture Decision Records (ADR). Each entry is numbered, dated, and self-contained. Discipline: Load-bearing architectural decisions are written here at the moment of making them. Decisions that live only in chat or in briefs are not canonical and may not be relied upon. Last updated: 2026-07-26 — ADR-076 filed to canon; ADR-075's pending-ratification note cleared (ratified at the DEV-DO-PARITY-01 Gate 5 sign-off, 2026-07-23).

Changelog discipline — read this before trusting the paragraph below. The running changelog that follows is closed at ADR-060 (2026-06-27) and is retained as history only. It was never maintained past that point: ADRs 047–057 and 061–075 carry no entry in it, and their absence records a lapse in the ledger, not an absence of changelog-worthy amendments. From ADR-061 onward the record is each ADR's own Status / Date / History lines, which are maintained on write and are authoritative. Do not read a gap in the paragraph as evidence that nothing happened. Recorded 2026-07-26 rather than backfilled — truth before paperwork; a reconstructed ledger would read as contemporaneous when it is not.

Changelog (historical, closed): 2026-06-27 (initial 13 ADRs filed 2026-05-26 morning; ADRs 014-018 appended 2026-05-26 evening; ADR-006 refined to clarify multi-provider posture; ADR-007 refined per substrate-state finding; ADR-018 refined three times — prefix discipline + vocabulary + env marker, then three-category taxonomy, then Q1b environment-neutrality clause; ADR-019 added for Mandarin enforcement at credential-surface layer; ADR-018 minor refinement per CANON-VS-ADR-018-RECONCILIATION-01 — worked-examples sharpened to use quickbooks consistently and rename-feasibility phase sequencing refined to reflect verified per-resource-type cost; ADRs 020-023 added 2026-05-27 — access-control three-tier model (T3/T4/T4A), plan/entitlement/metering substrate, operator support via impersonation pattern, graceful degradation across subscription state; ADR-007, ADR-008, ADR-012 each refined with composition notes mapping their existing content to the new tier model; ADR-024 added 2026-05-27 per IDENTITY-MODEL-RATIONALISATION-01 Phase 2 — canonical user-identity schema model: three-table shape (users + tier_grants + credentials) consolidating six L3-truth sources, with magic_link_allowlist as separate issuance gate and the issuance-gate-vs-tier-grant distinction canonicalised; ADR-025 added 2026-05-27 per IDENTITY-MODEL-PLACEMENT-DECISION-01 — identity model placement in new dedicated rto-identity-db (with -staging twin per Peel taxonomy); customer-facing workers route identity reads via internal-api service-binding per MANDARIN; ADR-026 added 2026-05-27 PM per IDENTITY-MODEL-MIGRATION-01 Phase 2 substrate-reality finding — cross-database relational constraints are application-layer concerns: D1 cross-DB FK references parse at CREATE TABLE but fail at INSERT time, so canonical schemas document cross-DB logical relationships as comments not REFERENCES clauses; ADR-024 Consequences amended with forward-pointer to ADR-026; ADR-027 added 2026-05-27 PM per ENVIRONMENT-NAME-RENAME-01 close — environment parity is a canonical commitment: schema parity + operational-vocabulary parity committed, data parity NOT committed per CANONICAL-IDENTITY-VIA-UI-ONLY; D1-name lag forward-referenced to queued D1-NAME-ENVIRONMENT-RENAME-01; dev/prod canonical environment names replace inherited dev/staging/prod three-stage convention; ADR-027 back-pointer to CANONICAL-IDENTITY-VIA-UI-ONLY reconciled 2026-05-28 at IMM-01 Phase 3c close — parenthetical in "Data parity is NOT committed" updated to point at standing-rules.md canonical-work cluster following the discipline's graduation from Tim-filed candidate to standing rule; ADR-028 added 2026-05-28 per OBSERVABILITY-SUBSTRATE-DECISION-01 — system observability named as canonical substrate and as a new MANDARIN category (Telemetry); live telemetry to Cloudflare Analytics Engine, audit/activity records to D1; grounds ADR-017's observability requirement with a storage-and-viewing layer; motivating failure case (load-dependent failure invisible without instrumentation) recorded; whole-machine uniform coverage committed, metric depth lean-start; apps/site ops-db write retirement landed as two narrower retire briefs (WAITLIST-LOOKUP-LOG-RETIRE-01 + APIREQUESTS-LOG-RETIRE-01) retiring 2 dead pipelines gone-is-gone; other 4 writes kept or parked; Analytics Engine + rto-audit-db substrate declared by this ADR but not yet provisioned (first-application framing factually reconciled 2026-05-29); ADR-029 added 2026-05-28 per audit-substrate convention review — uniform activity stream chosen over per-event-kind tables; canonical activity shape in rto-audit-db (id, timestamp, source, client_id, actor, action, detail) with client_id nullable to encode operator-vs-client distinction natively and actor format pinned to ADR-024 canonical identity (usr_… UUID for users, system:<worker-name> for system actors); cross-machine reconnaissance is one query filtered by time + source/client_id; column set is extensible by follow-on ADR, table shape is load-bearing; ADR-030 + ADR-031 added 2026-05-30 per INTERNAL-API-ISOLATION-PROGRAMME-01 — ADR-030 closes internal-api's forgeable-X-RTP-Internal-Source trust flaw by topological isolation (internal-api becomes binding-only with no public route; supersedes and retires the per-source-secret approach in INTERNAL-API-SOURCE-HEADER-AUTH-01), ADR-031 commits channel-separated service architecture as the canonical default (within-account = service binding, across-boundary = cryptographic auth, browser → same-origin route, fail-closed with no public-HTTP fallback, non-forgeable internal caller identity required — resolving the inside-job fork ADR-030 opened).); ADR-032 added 2026-06-06 per CLIENT-FILE-SCHEMA-01 — client-file ("golden record") placement in a new dedicated rto-client-db (+ -staging twin, Peel taxonomy per ADR-025), keyed by the canonical client_files.client_id; orgs billing/seat stays in ops-db; mirrors the clean rto_* Pith family; Type-1-shaped vessel (rto_code NOT NULL, not FK'd to rtos) with a nullable-rto_code evolution owed for Type-2/3; (changelog had lagged at ADR-031 — brought current 2026-06-07, truth-before-paperwork); ADR-033 added 2026-06-07 per Tim's framing call — the canonical client key is an opaque, immutable, system-minted id (cli_…, mirroring the ADR-024 usr_… precedent), issued for every client regardless of type; meaningful identifiers (rto_code, slug, human-facing reference number) are demoted to attributes hanging off the key, never keys themselves; redefines what ADR-032's client_id is (slug → opaque key); re-keys orgs/identity/client_files/CRM clients off the opaque key; input to CLIENT-RECORD-RECONCILIATION-01 + the prod-mint arc; must sequence before/with the orgs identity-field retirement; ADR-034 added 2026-06-07 per BILLING-CANON-01 — prod billing fails closed until go-live (prod internal-api carries no Stripe/QB secrets by design; the /billing/webhook 503 is the intended state until real-key go-live, not a regression); ADR-035 added 2026-06-08 per SETTINGS-MODULE-AUDIT-01 — token conformance is a canonical commitment: every themeable value resolves through a design token, never a literal (sibling to ADR-027's structural-conformance class; carries a one-time tokenisation remediation sweep + the apply-layer authoring §1.3/§1.4 the settings audit found absent); ADR-036 added 2026-06-08 per SETTINGS-MODULE-AUDIT-01 + Tim's product decision — user preferences are identity-scoped state (server-side, keyed to the user via workspace_prefs); device-local storage is a first-paint cache only, never the source of truth; Experience prefs migrate localStorage → workspace_prefs in EXPERIENCE-PREFS-IDENTITY-01; pairs with ADR-035 (035 makes settings render, 036 makes them follow you); ADR-037 added 2026-06-08 per the TOKEN-CONFORMANCE Stage A blocker — the canonical workspace theme model is a dark immersive shell over light document-like work surfaces (light = :root work-surface base, dark = override + fixed immersive treatment, teal #0e7490 canonical interactive, high-contrast for both bases); §1.2/§1.3/§1.4/§8.2/§8.3 are stale (describe a dark-default the product left behind) and get reconciled to verified globals.css reality in FOUNDATION-THEME-RECONCILE-01; canonises UI-RESET-01; no visual change; ADR-038 added 2026-06-09 per Tim's decision (taken after weighing the downsides) — org_memberships (a seventh L3-truth source ADR-024's consolidation didn't catch) folds into the canonical tier_grants grant record: membership-existence + tier become a tier_grants row, is_primary_admin → an is_primary flag, site_id → a grant field (site-scoped membership preserved), lifecycle → native grant columns (granted_by/granted_at/revoked_at per ADR-023) with status derived not stored (pending = open portal_invites, active = non-revoked grant, deactivated = revoked_at set); billing (billing_tier/billing_status) stays in ops-db as a deliberate HARD-SEPARATION domain wall (identity-db carries no financial data) and per-member billing drops as redundant with per-org subscription + seat count; the members list repoints to canonical /identity/members (tier_grants ⋈ users — the long-missing members-name join) and the record becomes UUID-keyed throughout via id_migration_map; user_tenant_roles also retired (zero internal-api refs, already named retired under ADR-024); tier_grants gains is_primary+site_id (both env twins, ADR-027 parity) with the federation-leanness trade-off recorded as acceptable (grant-relational, not foreign concerns; billing-out is the load-bearing leanness); composes with ADR-033 (opaque cli_… key) — distinct decisions (who-owns-membership vs what-the-key-is) sequenced together in brief 5 / CLIENT-RECORD-RECONCILIATION-01; substance is brief 5, pre-revenue so the data migration is one fixture row; ADR-039 added 2026-06-10 per CLIENT-CORRECTIONS-OVERLAY-01 (dev 77ab50ac) + CLIENT-CONTACT-CORRECTION-01 (dev 2d592f0d) — client corrections are a read-time overlay (client_field_corrections in workspace-db, org_id-keyed) composed server-side over the regulator-verified mirror (rto-client-db), never written in place so a re-pour can't clobber them; the regulator value stays visible/recoverable (never a silent overwrite); withdrawable not deleted (ADR-023, lifecycle_state='withdrawn'); field_key is a flat scalar namespace — composites decompose to dotted scalars (primary_contact.email, never JSON-in-value) for per-field correctability/recoverability; authz is T4-admin-on-own-client_id (T3 operators with NULL client_id can't assert), audited as a byproduct to platform_audit_log (interim → rto-audit-db per ADR-029); this is the second client-assertion overlay after studio_proposed_scope, so the third must consume a shared overlay module per the spine consolidation rule (likely third: scope-status corrections); ADR-040–043 added 2026-06-10 per the person-data decisions pass — ADR-040 the workforce person record (pc_persons, workspace-db, org-scoped) and its nullable user_id soft-link to the auth identity (users, identity-db), two concepts/two masters never merged, legal name single-homed on pc_persons.full_legal_name, find-or-link with admin-asserted matching (USI never a join key, email a non-binding suggestion), legacy people/people_tae retire; ADR-041 person-data cut by ownership/portability not topic (four classes: portable-self / person-credential / rto-relationship / verification) implemented as review-enforced ownership labels within workspace-db — no person-core database built (users.id is the already-existing portability conduit), verification as a column-group snapshot not an events table; ADR-042 mints T4B as a new tier_grants.tier enum value (person-tier, own-record self-service, uncharged + not seat-counted — tier-not-flag because seat-counting must stay structural and the master differs), tier_grants/impersonation_tokens CHECK widens ride the pre-revenue rebuild, assertion≠verification by write-path separation (self-verification permitted but legible), Option A/B dissolves into the PEOPLE-REGISTER-AUTHORSHIP convention, explicitly a non-trigger of ADR-039's overlay-consolidation rule; ADR-043 TGA-grounds training-product-identity fields (corpus-validated soft-ref incl. superseded codes, or explicitly non-TGA class with free-text permitted within that class only — the prohibited middle is an unvalidated string wearing a TGA code's clothes), enabling the Phase 3 currency engine (not built here), corpus supersession/currency edges a re-verification item; all four are canon-only, no runtime impact, execution in the merged seat-provisioning + person-data run.; ADR-044 added 2026-06-14 per the IDENTITY-ENTRY-PATH-01 design note — the canonical identity entry path: admin invites login facts only → person self-authors their own pc_persons as the gate to entry → signed attestation (timestamped/versioned audit artefact) → admin approval opens the grant (T4A seat consumed or T4B confirmed); rationale: self-attested data is the stronger compliance asset and the entry gate is itself a compliance control (un-attested operation made structurally impossible); reuses PERSON-RECORD-EDIT-PATH-01's edit path pointed at the person's own record; substrate prereq — tier_grants CHECK must add T4B before any T4B mint (Brick 2.5); Status Accepted (Tim, 2026-06-14); the T4B schema ripple ADR-042 scheduled was executed at Brick 2.5 (TIER-CHECK-T4B-WIDEN-01, dev+prod, 1b9388e8); ADR-045 added 2026-06-14 per the audit-emission design pass — the unified audit-emission discipline: one record wall (the activity stream, ADR-029), all surfaces emit through a single shared write helper enforcing a canonical contract (canonical-identity actor, append-only/immutable, timestamp-ordered), the heavyweight event-processing engine explicitly forbidden now and named as a future swap behind the helper seam, and the audit plane explicitly NOT the product-analytics source (measurement is ADR-028's Analytics Engine plane; the two walls stay separate permanently); ADR-044 amended same pass — the pending⇄returned-approved state machine settled, approval canonised as a recorded human co-sign (not rubber-stamp), the onboarding correspondence as an append-only immutable linear log emitting to the one stream via ADR-045 with People-card/onboarding-screen as filtered views, the signed declaration anchored as a point-in-time snapshot, IP capture committed as deliberate (privacy dimension named), requirement profiles as a seam-not-engine on the NOT NULL floor, re-attestation deferred; T4B prereq marked done (Brick 2.5); ADR-046 added 2026-06-14 per the correspondence-channel design pass — outbound email/SMS is a system-mediated channel (system owns mechanism, RTO owns voice); all sends route through one seam carrying default boilerplate (works out-of-the-box, no prefill), behind which per-org customisation arrives later without touching call sites; a touchpoint registry sub-page under Administration (sibling to Branding) lists every outbound touchpoint as a living work-in-progress inventory and is the forcing function (every send, including the pre-existing magic-link email as retroactive entry #1, lives on the list — a hidden hard-coded send cannot persist); the per-org editor is the registry's deliberate-future evolution, not built now; explicitly distinct from the audit record-of-send (ADR-045 — message bodies never enter the evidence store) and from SMS Connect (SMS=student-management integration) / Marketer (marketplace view), which are unrelated products; registry surfaces as part of Brick 3; CANON RECONCILIATION 2026-06-15 (end-of-run, post Bricks 3a+3c equalise — prose only, truth catches up to shipped substrate) — (1) actor-identity: corrected the phantom usr_… user-key in ADR-029 + ADR-033 (and ADR-045's actor description) to ADR-024's actual canon, a bare RFC 4122 UUID; usr_… was never established for users (clients carry opaque cli_…, users bare UUID — distinct disciplines by design); the emission helper's bare-UUID-accepting actor validation is now canon-correct, not interim; (2) audit-db naming: rto-audit-db shorthand → provisioned names rtopacks-audit-prod 5505ec39… / rtopacks-audit-dev 8c019cf6… across ADR-028/029/039/045 (Cloudflare Naming Canon; identity-db/client-db/ops-db ADR names verified already matching live, no drift); (3) ADR-044 snapshot-substrate-form question resolved at Brick 3c — dedicated versioned pc_attestations artefact (workspace-db, append-only, full-snapshot), activity carries only an {attestation_id, version} pointer; (4) PC-SPEC-01 v0.3 stale build-state reconciled (edit path + Bricks 2/2.5/3a/3c shipped). ADRs stay Accepted; dated reconciliation notes added inline.; ADR-007 + ADR-024 reconciled 2026-06-15 per PASSKEY-CREDENTIALS-SCHEMA-DIVERGENCE-01 — passkey placeholder fork resolved to DROP (table gone both envs, never authenticated, off-surface/off-canon, live email-keying hazard removed with it); the scheduled passkey_credentials → credentials migration mooted (dropped not migrated, 3 SYSTEM_ACTOR rows); ledger-drift noted (IDENTITY-MODEL-MIGRATION-01.sql marked the rows RETIRED while the live table drifted past it); canonical credentials table preserved empty for a future provider build guarded by assertCanonicalUserId; D1 --file silent-no-op gotcha filed to standing-rules D1 cluster.; ADR-058 added 2026-06-25 per SEAT-ENFORCEMENT-AND-CHURN-01 — seat enforcement is reserve-on-invite + a one-source seat count + refuse-into-remedy-socket + churn governed by a reactivation cooldown, all read from the T3/T4 seam (T3 owns the numbers, T4 honours them, never hard-coded); Proposed — Phase A (count/reserve/refuse + seam read, this commit) proves the count half, the cooldown/reactivation churn half + the Accepted flip land in Phase B; ADR-058 amended 2026-06-25 — commitment 3 sharpened with the named (A)/(B) operational split (A = reinstate a revoked invite, team-page, clean-free; B = reactivate a deactivated member, person-drawer, cooldown-gated; no operation spans both) per the Gate-1 spanning-reinstate finding (a single spanning reinstate would have no-op'd on grantless fixtures), and the Phase B substrate-prereq (surface deactivated members in the §1.9 Revoked section) recorded; sharpening only, Status stays Proposed; ADR-058 amended 2026-06-25 (move-3 drip-1 build) — the cooldown gate mechanism recorded: the clock starts at seat-CONSUMPTION not deactivation (revoked_at = FIFO order only — supersedes the banked "clock off revoked_at" phrasing), new nullable tier_grants.cooldown_expires_at (INTEGER epoch UTC, on the T4A grant; one column encodes revoked-free vs revoked-locked + the FIFO predicate), three consumption triggers (invite-reserve incl. reinstate / member reactivation / promote-to-T4A — NOT net-zero approval) each guarded by the capacity invariant (used + free-deactivated > seat_limit, else no lock — consuming spare capacity locks no one), the two unlock doors + bought-seat-is-the-admin's-free-choice (no earmarking), and the per-tier-cooldown-flex deferral (flat 7-day stub SEAT_COOLDOWN_PERIOD_SECONDS=604800, ADR-034); Status stays Proposed (flips at move-3 drip-2's walk); ADR-058 FLIPPED Proposed→Accepted 2026-06-26 at the move-3 drip-2 walk — the cooldown drawer surface (self-ticking amber countdown off cooldown_expires_at, revoked-FREE→Reactivate / revoked-LOCKED→gated padlock+countdown, the four-row date+time stack + Revoked row, invited_by UUID→name resolution incl. a users fallback, status display-language) built + walked to terminal effect on dev; the drip-1 hazard (epoch-int countdown) confirmed crash-free and a frozen-countdown SSR-hydration bug caught at the walk and fixed (mounted-flag placeholder); the churn model is exercised end-to-end, the proving brick earns Accepted; ADR-059 added 2026-06-26 per EMAIL-VALIDATION-CANONICAL-01 — ONE canonical email validator (@rtopacks/validation, zero-dep, all 3 runtimes; client mirror re-exported from @rtopacks/ui; server is truth, client is summoned-on-denial UX), STRUCTURAL-ONLY pragmatic subset (well-formedness not reachability; explicit NO known-TLD-list / NO DNS-MX — admin@ucca.colle passes and dies at the magic-link, the real deliverability gate), the one-home forbidding rule (any other email check is a defect to delete — the ADR is the doorstop the 3 duplicated weak regexes lacked), and a LIVE owed-surfaces inventory (2 wired+proven / ~7 owed / 3 weak regexes owed-for-deletion; done = list empty) as the forcing function; Proposed — flips Accepted at the two-surface walk (refused-malformed-reserves-no-seat + accepted-valid), not on construction; foundation §12 extended (v1.35) with the behaviour rule, maintenance-on-write in the proving commit; ADR-059 FLIPPED Proposed→Accepted 2026-06-26 at the invite-gate walk (malformed refused → seat held at 2; valid accepted → seat to 3; both arms under Tim's eyes) — the rule is proven; the owed-surfaces inventory stays the live forcing function until empty (entry-path server backstop + ~6 other surfaces + 3 weak-regex deletions remain owed); EMAIL-VALIDATION-PROPAGATION-01 (2026-06-26) EMPTIED the owed-list to ZERO — Move 1 (4f5f7337) wired the 6 safe surfaces + admin's first @rtopacks/validation file: dep; Move 2 wired both logins + DELETED the 3 weak regexes (replace-not-supplement); both walked (incl. the deliberate login walk — valid accepted, no lockout; allowlist-domain conditional proven). ADR-059 enforcement now system-wide; ADR stays Accepted (fulfilment edit, not a status change); ADR-060 added 2026-06-27 per BRIEF-ADR-060-FILE-01 — seat mutations serialise through a per-org Durable Object, completing ADR-058 commitment #2's concurrent-write TOCTOU class (reserve-on-invite closed the invite over-issue but not the concurrent-write window the recon found still open across all claimers): a single-threaded per-org DO is the serialisation gate in front of every seat writer (reserve/reinstate/promote/reactivate claimers, revoke/deactivate releasers, the not-independently-guarded mintGrantsForInvite convert-pair), while the authoritative count stays DERIVED from D1 (tier_grants + portal_invites vs ops-db seat_limit) — the DO serialises writes, it does not own the count (a stale read never breaches a cap, only a double write does); HARD SEPARATION preserved (seat_limit stays in ops-db via the OPS-SURFACE-RULE seam — the single-DB restructure rejected as a boundary move for SQL convenience, DO-owned-count rejected as reopening the count-drift class); Status Proposed at filing — FLIPPED → Accepted 2026-06-27 at the multi-run concurrent-write walk; the first DO build (896ab49c) serialised requests but NOT the D1 critical section (a DO's input gate covers only its own storage, not external I/O like D1) and reproduced 5-of-4 by interleave — NOT replica lag (replication disabled on identity-db); fixed with blockConcurrencyWhile (BRIEF-SEAT-DO-CONSISTENCY-01, 5e39b6a5 supersedes 896ab49c), count stays single-source in D1.)


How this doc works

Each decision is captured as an ADR with five parts:

  • Title — short noun phrase naming the decision
  • Status — Accepted / Superseded by [N] / Deprecated
  • Date — when the decision was made
  • Context — what situation made this decision necessary
  • Decision — what was decided, in concrete terms
  • Consequences — what follows from the decision; what changes; what is now ruled out

Decisions are not edited in place. When a decision is superseded, the original entry remains with its status updated; a new entry is added with the new decision and a reference back to what it supersedes. This preserves the historical record of how RTOpacks' architecture evolved.

When a brief, module spec, or implementation references an architectural decision, it cites the ADR number (e.g. "per ADR-003"). This makes the dependency chain visible and prevents decisions from drifting back into chat-only or brief-only existence.


ADR-001 — RTOpacks operates a closed-loop architecture

Status: Accepted Date: 2026-05-26

Context. RTOpacks could be framed as a collection of features (modules) or as a closed-loop product (Sense → Decide → Execute → Deliver and Assess → Evidence). The framing determines how new capability is evaluated and how the product is sold.

Decision. RTOpacks operates as a five-phase closed loop. Every module, every new capability, every architectural decision is evaluated against whether it extends the closed loop coherently or fragments it. New surfaces that do not fit cleanly into one of the five phases are either re-scoped to fit, deferred until the right phase shape exists, or rejected.

Consequences. - Module specs are mapped to phases of the loop, not catalogued as independent features. - Future modules (e.g. student management, native LMS) must demonstrate how they extend the loop, not just what they do. - Competitive positioning is "we close the loop," not "we have feature X." - The substrate moat (ADR-002) and the architectural coherence (ADR-016, ADR-017) are what make machine-speed closure possible; all three are foundational to this decision.

See client-spine.md Section 2 for the full articulation.


ADR-002 — The substrate is the moat

Status: Accepted Date: 2026-05-26

Context. RTOpacks has ingested multiple Australian government data sources (TGA, yourcareer, ABS labour data, government funding and tender data, and others) over the course of building. The strategic significance of this substrate has not been canonically named in the docs corpus until now.

Decision. The ingested data substrate is RTOpacks' primary competitive moat. Product surfaces are derived presentations of the substrate. Strategic decisions — what to build, what to integrate, what to charge for — are evaluated against whether they leverage the substrate or operate independently of it.

Consequences. - Substrate ingestion is treated as foundational work, not as feature support. - Sync infrastructure (the IRSL pattern, Sync Watchtower, run-status logging) is the integrity layer for the moat and is funded operationally as such. - The hoover-mentality principle (ADR-003) and the pristine-source principle (ADR-004) follow from this decision. - The four moats — operational, continuity, historical, analytical — are named in the spine doc and inform which substrate work gets prioritised.

See client-spine.md Section 4.


ADR-003 — Substrate ingestion follows hoover-mentality

Status: Accepted Date: 2026-05-26

Context. Conventional engineering discipline holds that data should be ingested only when a current feature requires it. For a substrate-as-moat business, this discipline is structurally wrong: it produces a substrate that matches current need but not future opportunity, and it does so during a window of access that may close.

Decision. RTOpacks ingests what upstream sources expose, not only what current features consume. Discipline around what to mirror is calibrated to access-window availability and source completeness, not to current-feature need. New sources are evaluated under the same posture: if access is open and the data is relevant to the regulated training market, ingestion is the default action.

Consequences. - Storage costs are paid up-front and accepted as the price of holding substrate that competitors cannot retroactively obtain. - Marginal storage cost is treated as low; marginal future-value of historically-captured data is treated as high. - Ingestion decisions are made greedily within the access window; organisation of derived value happens after ingestion via the recompute-as-needed principle (ADR-005). - This decision must not be reversed by anyone applying conventional engineering discipline to it. Reversal requires explicit ADR superseding this one with named reasoning.

See client-spine.md Section 4 ("Working principles for the substrate").


ADR-004 — Ingested data is held pristine

Status: Accepted Date: 2026-05-26

Context. Ingested data could be cleaned, normalised, or corrected at ingestion time for convenience or analytical consistency. Each such modification, however small, converts upstream truth into RTOpacks-shaped opinion. The moat's value depends on the substrate's faithfulness to upstream at the moment of ingestion.

Decision. RTOpacks does not modify ingested data for any reason. Not for cleanup, not for normalisation, not for correction, not for convenience, not for analytical consistency. Layer 1 (the ingested raw) is immutable from RTOpacks' perspective. All modifications, normalisations, joins, and corrections happen in Layer 2 (derived/computed) or Layer 3 (client/operational state), which reference but do not mutate Layer 1.

Consequences. - No fields are added to mirror tables (including no is_client flags, no status annotations, no derived columns). - Sync workers may add operational metadata (synced_at, mirror_version) on metadata tables adjacent to but separate from the substrate tables themselves; these do not violate the principle because they describe the sync operation, not the upstream data. - Diff-detection between client self-reports and upstream-sourced data is implemented via the client file (Layer 3) referencing the mirror (Layer 1), never by mutating the mirror to record client positions. - Cross-source joins, aggregations, and computed views live in Layer 2 and are recomputable from Layer 1 at any time. - This decision is load-bearing. Future briefs that propose any modification to ingested data must justify the modification against this ADR and either supersede it or accept the no-modification posture.

See client-spine.md Section 4 ("The three-layer data architecture", "Working principles for the substrate").


ADR-005 — Derived layers are recomputable

Status: Accepted Date: 2026-05-26

Context. Derived tables and computed views could be treated as precious — patched when wrong, evolved in place, accumulated over time. This produces a derived layer that becomes its own form of accreted state, with the same drift and silk-thread problems as the operational layer.

Decision. Layer 2 (derived and computed) is recomputable from Layer 1 at any time. Derived tables are not precious. When derivation logic is wrong or improved, the derived layer is rebuilt from Layer 1 with the new logic. Derivation logic lives in version-controlled code; the derived tables themselves are caches of that logic's outputs.

Consequences. - New analytical questions do not require new ingestion; they are computed from substrate already held. - Storage costs are paid for Layer 1; compute costs are paid as Layer 2 derivations are needed. - Derived tables can be dropped and rebuilt without permanent loss, provided the derivation logic is preserved in code. - This decision is what makes the pristine-source principle (ADR-004) operationally workable — there is no temptation to "fix" Layer 1 because Layer 2 is freely rebuildable.

See client-spine.md Section 4 ("The three-layer data architecture").


ADR-006 — Cloudflare-first for all new capability, including identity

Status: Accepted Date: 2026-05-26 Builds on: the CLOUDFLARE-FIRST RULE in standing-rules.md Refined: 2026-05-26 (evening) — clarified that external providers are gap-specific, not wholesale replacements; multiple targeted providers may fill different gaps.

Context. RTOpacks is built top-to-bottom on Cloudflare. New capability needs — including identity and credential management — could be met by external vendors (Auth0, Clerk, WorkOS, etc.) or by Cloudflare-native primitives. The CLOUDFLARE-FIRST RULE says Cloudflare is asked first; this ADR confirms that the rule applies specifically to identity and credential management, and articulates the reasoning.

Decision. For all new capability — and explicitly including identity and credential management — Cloudflare is evaluated first. The evaluation asks: does Cloudflare provide this; is Cloudflare shipping this; is the gap one Cloudflare structurally cannot close. External vendors are entertained only at the third question.

For identity specifically, the three customer-type requirements (Type 1 / Type 2 / Type 3 signup flows; email verification; SMS verification; session management; auditable identity events; OIDC scaffolding for future federation) are mapped against Cloudflare's current and announced identity capabilities. If Cloudflare can meet the requirements, Cloudflare wins. If Cloudflare cannot meet a specific requirement, that gap is the only basis for entertaining an external provider for that specific gap.

Consequences. - Operational property of "one throat to choke" is preserved at the Cloudflare layer — debugging, support, and incident response for Cloudflare-served capability stay within a single vendor surface. - Substrate coherence is preserved — Cloudflare-native integration removes connector code, secret management, callback URL plumbing, and webhook reconciliation that would otherwise become silent-thread architecture. - Federation (enterprise customers bringing their own IdP) is treated as a future credential type, integrated via Cloudflare's OIDC capability when available. - External providers, when entertained, are gap-specific rather than wholesale replacements. Multiple external providers may end up filling different specific gaps (e.g. SMS verification per ADR-014, OIDC federation if Cloudflare cannot deliver it, anything else where the gap is real). Each gap-filling provider gets its own ADR. The combination is "Cloudflare-served capability + targeted external providers per specific gap," not "single external IDP replacing Cloudflare." - Each external provider integration follows ADR-015 (full-API-wrapper pattern) and the EXT-API RULE in standing-rules.md (reference doc required before deploy). - The specific identity-provider decision is captured in CREDENTIAL-PROVIDER-DECISION-01 (a pending brief), which will execute the evaluation described above and close with specific recommendations per gap rather than a single-vendor answer.

See client-spine.md Section 7.


ADR-007 — Users and credentials are separated at the schema level

Status: Accepted Date: 2026-05-26 Refined: 2026-05-26 (evening) — consequence #3 updated to reflect substrate finding that passkey_credentials has sign_count=0 on both rows; "prototype" softened to "placeholder."

Context. A user (the thing that has roles, takes actions, attaches to client files) and a credential (the thing that authenticates a user) are different concepts. Conflating them in the schema produces an identity model that cannot support multiple credential types, cannot support federation later, and cannot support credential rotation without a schema migration.

Decision. Users and credentials are separated at the schema level. A user is an entity (with role, client attachment, session ownership, action history). A credential is a separate entity (with type, identifier, validity window, parent-user reference). A user can have multiple credentials over time; a credential authenticates one user.

Consequences. - Credential management is outsourced (ADR-006). The internal user model holds references to externally-managed credentials, not the credentials themselves. - Multiple credential types per user (passkey, OIDC-via-IdP, magic-link, etc.) are supported by the schema without further changes. - Federation (ADR-006) is implemented as adding a new credential type, not as building a separate user system. - Credential rotation, MFA enrolment, and credential revocation are operations on credential entities, not on user entities. - The current ops-db passkey_credentials table is the placeholder for this separation, not the prototype. OPS-DB-CONTENT-AUDIT-01 found sign_count=0 on both enrolled rows, meaning verification has never succeeded against the table; the principle has not been exercised by real authentication flow. ADMIN-AUTH-MODEL-RECONCILIATION-01 (likely subsumed by CREDENTIAL-PROVIDER-DECISION-01) is the brief that resolves whether the placeholder graduates to a real prototype, gets dropped, or gets replaced by outsourced credentials. - Resolved 2026-06-15 (PASSKEY-CREDENTIALS-SCHEMA-DIVERGENCE-01). The parked placeholder fork (graduate / drop / replace) resolves to DROP. passkey_credentials was dropped both envs — never authenticated (sign_count=0 throughout its life), no login ever depended on it (operational auth is CF Access + magic-link), off-surface (ops-db, a HARD-SEPARATION smell for an identity-bearing record), off-canon (ADR-024 scheduled it gone), and carrying a live email-keying hazard (caller.user_id || caller.email binding into an un-FK'd identity slot, silent because the cross-DB split means no FK fired). The 3 prod rows were all SYSTEM_ACTOR scaffolding — no data worth keeping. The canonical credentials table (identity-db) is preserved empty for a real future provider build under CREDENTIAL-PROVIDER-DECISION-01, which lands fresh and guarded by assertCanonicalUserId (the standing rule filed alongside INVITED-BY-RESOLUTION-REGRESSION-GUARD-01). ADMIN-AUTH-MODEL-RECONCILIATION-01 is thereby answered for passkey: dropped, not graduated. - The identity model rationalisation work (IDENTITY-MODEL-RATIONALISATION-01, pending brief) resolves the four current identity conventions into one that honours this principle. - Composition with ADR-020. The "user" entity in this ADR maps to T4A (client user tier) in ADR-020. T4 administrator role is held by a user but is conceptually distinct from the user identity. T4A users are separated from credentials per this ADR; the T4 administrator role attaches to a T4A user identity rather than existing as its own credential-bearing entity. Identity rationalisation work (IDENTITY-MODEL-RATIONALISATION-01) resolves the substrate state to honour both this separation and ADR-020's tier model.

See client-spine.md Section 7.


ADR-008 — No platform tier above clients

Status: Accepted Date: 2026-05-26

Context. The current ops-db schema contains tables (accounts, account_worlds, account_memberships, custom_roles) that imply a tenancy tier above clients — a platform-level concept that holds RTOpacks (and notionally other future products) above the clients RTOpacks serves. This structure was inherited from an earlier mental model that treated RTOpacks as downstream of a broader UCCA universal-credential thesis.

This mental model is no longer accurate. RTOpacks is its own product serving its own market. UCCA Inc and UCCA Pty Ltd are corporate entities, not architectural tiers. There is no platform-tier abstraction that customers belong under.

Decision. RTOpacks does not implement a platform-tier abstraction above clients. The architectural hierarchy is: client file → users-attached-to-clients → modules-as-lenses. If a future product requires a tenancy model above clients (e.g. white-label resale, multi-product platform), it will be added deliberately at that point with clear product reasoning, not retained speculatively as legacy scaffolding.

Consequences. - The five identity-tangle orphan tables (accounts, account_worlds, account_memberships, custom_roles, customers) surfaced in OPS-DB-CONTENT-AUDIT-01 are drop candidates. The drop is executed via a downstream brief (likely OPS-DB-IDENTITY-ORPHAN-CLEANUP-01) once the spine doc is canonical. - Any future feature request that implies a platform tier (e.g. "white-label this for another reseller") triggers a deliberate ADR conversation, not an ad-hoc schema extension. - The corporate separation (UCCA Inc holds the engine; UCCA Pty Ltd operates RTOpacks) is honoured in legal structure, not in product schema. - Composition with ADR-020. T3 (operator tier) per ADR-020 is the substrate-operating tier, not a commercial tier above clients. This ADR's statement that there is no platform tier above clients remains accurate at the commercial layer: clients do not belong under a commercial parent entity. T3 operates the substrate that clients use; T3 does not commercially sit above clients in a tenancy hierarchy. The two architectural facts compose without conflict.

See client-spine.md Section 1 ("The 'RTOpacks didn't start here' note") and Section 7.


ADR-009 — Client-file attachments are deliberate

Status: Accepted Date: 2026-05-26

Context. A large amount of operational state hangs off the client file — Studio sessions, generated content outputs, staff work products, drafts, exports, version histories, audit decisions, delivery records, assessment outcomes, correspondence threads. The temptation under feature pressure is to attach new state ad-hoc, with whatever shape the immediate feature demands. This produces silk-thread architecture: relationships that exist but are not visible at the schema level, inferred at runtime rather than asserted at design time.

Decision. Every attachment to the client file is deliberate, named, and broadcasts its intent clearly. Solid, thoughtful anchorage points that any future module or surface knows how to use. Before a new attachment is built, the four substrate questions are answered: what does it know, where does it live, who sees it, how does it fail. If those four answers do not exist, the attachment is not built.

Consequences. - Module specs include their client-file attachment shape as a first-class section, not as implementation detail. - New attachments require their access-pattern, permission model, lifecycle (creation/update/archival/deletion), and failure mode to be specified before implementation begins. - The four substrate questions are a standing checklist applied to every new attachment, captured in spine § 5 and referenced from this ADR. - This ADR is foundational to the broader ADR-016 modular-consolidation principle: every client-file attachment is a candidate for consolidation if the same shape of attachment appears more than once.

See client-spine.md Section 5.


ADR-010 — Export-portable output is a first-class capability

Status: Accepted Date: 2026-05-26

Context. RTOpacks could choose to lock clients into native delivery (RTOpacks-served LMS only) as a strategic moat, or could treat export to client-owned LMSes as a first-class capability. The first option maximises platform stickiness; the second option respects client portability and avoids vendor-capture perception.

Decision. Export-portable output is a first-class capability. UCCA / Studio output formats include SCORM, print, and other exportable formats. Clients who prefer their own delivery infrastructure use export; clients who prefer RTOpacks-native delivery use InstaLearn (or the regulated-LMS surface as it matures). Both paths are first-class, not "native + lesser export."

Consequences. - Studio's output pipeline produces export-compatible artefacts as a first-class workflow, not as a special-case feature. - The closed loop (ADR-001) is honoured for clients who use RTOpacks' native delivery; for clients who export, the loop is intentionally open at the Deliver-and-Assess phase, with RTOpacks providing the artefacts and the client providing the delivery substrate. - This decision protects RTOpacks from the perception of vendor lock-in and aligns with serving regulated professionals who value their portability. - When the LMS surface expands into regulated delivery, this ADR is the principle that prevents the LMS from becoming a closed system by accident.

See client-spine.md Section 6 ("The six current modules" — Deliver and Assess).


ADR-011 — Three customer types with explicit data-provenance posture

Status: Accepted Date: 2026-05-26

Context. RTOpacks serves more than one kind of customer. Treating them as homogeneous produces a schema and product surface that fits none of them well. The three types have fundamentally different relationships to regulatory authority and to the substrate.

Decision. RTOpacks recognises three customer types:

  • Type 1 — Registered RTO. Listed in TGA. Client file auto-populated from the mirror at signup. Drift between self-reported state and regulator-sourced state is a first-class product surface.
  • Type 2 — Pre-registration RTO. Not yet in TGA. Self-reports into TGA-shaped schema. Graduates to regulator-verified status at TGA registration via a confirmation step.
  • Type 3 — Non-RTO content creator. Independent course developers, corporate trainers, freelancers. Different product surface, different commercial terms. Includes potentially adversarial users (competitors scoping the tooling); surface is designed to be useful for legitimate Type 3 use while structurally non-leaky of Type 1/2 substrate value.

Every field in the client file carries a provenance marker: regulator-verified, self-reported, derived, or RTOpacks-created. Provenance is a load-bearing schema property that determines how each field is treated by consuming modules.

Consequences. - The signup flow (ADR-012) varies by type but uses a single underlying mechanism. - Module entitlements may vary by type — some modules are Type 1-only (Radar, Record), some span all three (Studio, possibly InstaLearn). - The Type 2 → Type 1 graduation is a switch-flip with confirmation, not a data migration; field provenance updates but the data itself does not move. - Type 3 product surface design includes deliberate consideration of competitive-intelligence risk — what is shown to a Type 3 user must not leak the substrate's analytical value to competitors.

See client-spine.md Section 3.


ADR-012 — Signup is open; admin authority is asynchronous

Status: Accepted Date: 2026-05-26

Context. Multi-user customers (especially TAFEs and larger RTOs) need a signup model that handles "who signs up first," "who has admin authority," and "what happens when the second user signs up later." Gating signup on CEO availability creates friction; making the first signup automatically the admin creates security risk.

Decision. Account creation and administrative authority are decoupled.

  • Whoever signs up first from an RTO domain (verified via TGA mirror + SMS) creates the client file and gets a pending-verification user role.
  • Administrative authority is established asynchronously via CEO notification (email to the CEO on file in TGA, with a three-button decision interface: confirm this user, set up your own admin, report as unauthorised).
  • Until the CEO acts, the first signup operates in pending-verification mode with limited access.
  • Subsequent users from the same RTO domain follow the identical flow: pending user attached to existing client file, notification to current primary admin, same three-button decision.

Consequences. - One signup flow to design, build, test, and maintain — not separate flows for first user versus Nth user. - The new user's experience is not gated on the CEO's responsiveness. - The CEO email is a verification channel, not a permission-grantor — they make a single decisive choice, they don't run admin operations. - Soft escalation paths exist for the case where the CEO never responds. - Composition with ADR-020. The "first signup creates the client file with pending-verification role" pattern in this ADR composes with the T4 administrator establishment in ADR-020 as follows: the first signup creates the client file and is provisionally attached as a T4A user with a pending T4 administrator role. The CEO notification flow described in this ADR is what establishes T4 administrator authority canonically — once the CEO confirms, the first user holds both a T4A user identity and the T4 administrator role. Subsequent users from the same RTO domain follow the identical flow per this ADR, defaulting to T4A user identity with no T4 administrator role unless explicitly granted by the current T4 administrator.

See client-spine.md Section 8.


ADR-013 — RTOpacks delivers two streams that compose into one product

Status: Accepted Date: 2026-05-26

Context. RTOpacks' capabilities span strategic intelligence (where the money is) and compliant execution (the operational machinery of being an RTO). These could be framed as two separate products that share infrastructure, or as one product with two streams that compose into a coherent thesis.

Decision. RTOpacks delivers two streams that compose into one product:

  • Strategic intelligence stream — surfaces where the money is, what the market wants, where the gaps are, how the client's current scope is performing.
  • Compliant execution stream — produces training, assessment, and audit evidence to the letter of the law.

Either stream alone has limited commercial value. Strategic intelligence without compliant execution is knowledge with no operational expression. Compliant execution without strategic intelligence is either inertia or guesswork. The two streams compose into the closed loop (ADR-001).

The streams may be commercially packaged separately if market reach demands it, but the architectural commitment is to one coherent product, not two independent products that happen to share data.

Consequences. - Marketing and sales messaging articulates the two-stream framing, not a feature catalogue. - Pricing and packaging decisions can offer the streams together or apart, but the integration is the value proposition. - Module specs are mapped to one or both streams; new modules are evaluated against which stream they extend. - The duck-and-shark example in client-spine.md Section 6 is the canonical illustration of why both streams compose.

See client-spine.md Section 6.


ADR-014 — SMS is outsourced to Cellcast and wrapped as a shared internal module

Status: Accepted Date: 2026-05-26

Context. RTOpacks needs SMS capability for the signup verification gate (per spine § 8 — paired with email matching as the two-factor signup verification) and for future use cases including client-side messaging (RTOpacks-to-client communications, client-to-student communications), 2FA flows if RTOpacks ever holds credentials internally, and possible sub-client SMS provisioning if RTOpacks ever offers SMS-as-a-feature to client RTOs.

The choice of provider matters because it constrains pricing, geographic coverage, API quality, and the integration discipline. The Cloudflare-first rule (ADR-006) was applied: Cloudflare does not offer native SMS sending. SMS is therefore a gap Cloudflare structurally cannot close, and an external provider is the correct answer.

Provider evaluation landed on Cellcast (Melbourne, Australia) over alternatives including Twilio and ClickSend, on the basis of: AU-native operation with AU-onshore data (matching RTOpacks' primary AU customer base, with US/UK extension available if needed); ISO 27001 certification; lower cost than Twilio for AU sending; published OpenAPI specification available for download at developer.cellcast.com/cellcast-api.swagger.yaml; a substantial API surface (~50+ endpoints) including features RTOpacks may want in the future (sub-client account provisioning, RMS two-way chat, virtual number purchasing, opt-out management, file scheduling, 2FA flows, custom short URLs); and an existing Cellcast account already established by RTOpacks for production use.

Decision. RTOpacks uses Cellcast as its SMS provider. Specifically, the Enterprise Platform API surface documented at developer.cellcast.com (not the older Classic Platform). SMS access is mediated by a shared internal module (the SMS module) that wraps Cellcast's Enterprise Platform API. All RTOpacks code that sends or receives SMS routes through this module; no consumer makes direct Cellcast API calls.

The SMS module follows the external-service integration pattern (ADR-015) — it wraps Cellcast's full published API surface, including endpoints RTOpacks does not currently consume.

Consequences. - The Twilio scaffolding currently present in the substrate (interaction_log.twilio_sid columns and any related code surfaced in OPS-DB-CONTENT-AUDIT-01) is superseded by this decision. A downstream brief (SMS-CURRENT-STATE-AUDIT-01 or similar) audits what Twilio integration is actually wired vs scaffolded, and the eventual SMS module brief replaces or removes it. - The SMS module is substrate-shared infrastructure, not module-specific. Studio, Record, the signup flow, and any future module needing SMS all consume it. - The Cellcast OpenAPI spec at developer.cellcast.com/cellcast-api.swagger.yaml is the contract the wrapper mirrors. The full API is wrapped per ADR-015; Cellcast endpoints RTOpacks does not currently consume (sub-client accounts, virtual number purchase, international sending, RMS chat, file scheduler, custom short URLs, etc.) are wrapped against future use. - Per the EXT-API RULE in standing-rules.md, a reference doc at docs/ops/cellcast-api-reference.md is required before the SMS module deploys. This doc covers endpoint inventory (from the OpenAPI spec), auth model, rate limits, known quirks, sandbox vs production posture, and example requests. The SMS module brief includes drafting this reference doc as part of its scope. - Provider-swap stays clean if Cellcast ever needs to be replaced. The wrapper's interior changes; every consumer of the wrapper stays unchanged. - ADR-006's external-provider clause is exercised by this decision. Future identity-related external providers (OIDC for federation, anything else Cloudflare cannot fill) follow the same pattern: gap-only, full-API-wrapped, behind a shared internal module.

See client-spine.md Section 8 (signup gate) and Section 7 (Identity and access; SMS is verification infrastructure, distinct from credential management).


ADR-015 — External service integrations wrap the provider's full API surface

Status: Accepted Date: 2026-05-26

Context. When RTOpacks integrates with an external service, there are two patterns the integration can follow. The minimal pattern wraps only the endpoints currently consumed by features: write only what is currently used, expand the wrapper as new uses arise. The full-surface pattern wraps every endpoint the provider exposes, regardless of which endpoints current features consume.

The minimal pattern is conventionally the engineering-disciplined choice. For RTOpacks' situation — small team, substrate-as-moat thesis, intent to compose external services into a coherent platform — it is structurally wrong. The minimal pattern produces three drag effects: every new feature that needs an unwrapped endpoint requires wrapper-extension before feature work begins; debugging routes through "wrapped paths" and "ad-hoc paths" inconsistently; and provider-swap forces auditing every consumer to confirm which endpoints they actually use, because the wrapper itself does not encode the full possible surface.

This ADR is one specific application of the broader modular-consolidation principle (ADR-016): when the same shape of work — external-service integration — appears repeatedly, the discipline is to consolidate into a shared pattern rather than reinvent per-integration.

Decision. When RTOpacks integrates with an external service, the internal module wraps the provider's full published API surface. Every documented endpoint gets a corresponding function in the wrapper, even if no current feature consumes it.

If the provider publishes a machine-readable specification (OpenAPI/Swagger, GraphQL schema, gRPC proto), the wrapper is anchored to that specification — either autogenerated from it, programmatically validated against it, or hand-written but verified against it via tooling.

If the provider publishes only prose documentation, the wrapper is hand-written to mirror the documented surface, and stays in sync via deliberate maintenance.

Consequences. - New features that use external services never require "first add the wrapper, then build the feature." The wrapper is already present. - Debugging routes through one consistent layer per external service. Every Cellcast call, every Stripe call, every QuickBooks call goes through its wrapper. No ad-hoc bypass paths. - Provider-swap is clean. The wrapper's interior changes; consumers stay unchanged. Auditing consumers for hidden direct calls is unnecessary because direct calls are not permitted. - The wrapper effort is up-front rather than amortised. Initial integration is slightly larger work than the minimal pattern; subsequent feature work that uses the external service is substantially smaller. The trade is correct for RTOpacks' situation: substrate-as-moat businesses pay for completeness early because completeness is what enables future composition. - Cellcast (ADR-014) is the canonical first application of this pattern. Every future external service integration — including any external OIDC provider that lands per ADR-006, any future payment processor evolution, any analytics integration — follows the same posture. - Wrappers are version-controlled internal infrastructure. When a provider updates their API (adds endpoints, deprecates endpoints, changes contract), the wrapper update is treated as substrate work, not as ad-hoc maintenance against a specific consumer's needs.

This ADR is load-bearing for substrate composition. Reversing it requires explicit ADR superseding this one with named reasoning. Arguments of the form "we only need three endpoints right now, let's just wrap those" do not justify reversal; they describe the failure mode this ADR prevents.

See ADR-014 (Cellcast as canonical first application), ADR-016 (the broader modular-consolidation principle this exemplifies).


ADR-016 — Modular consolidation over ad-hoc attachment

Status: Accepted Date: 2026-05-26

Context. A recurring pattern has appeared multiple times in RTOpacks' substrate history: code written for an immediate need, attached at the point of use, never abstracted to a form that the next similar need could consume. The instances are not identical in shape but share a common form — each one solves a specific problem in isolation, and the substrate accretes variant solutions to similar problems without recognising the underlying pattern.

Documented instances of this pattern in RTOpacks' history:

  • Sync ops before the IRSL pattern. Each sync worker had its own ad-hoc logging. Each one solved "did this run actually fire" differently — or didn't solve it at all. The IRSL brief was modular consolidation: same pattern, same shape, applied across workers via a shared discipline.
  • Run-status classification before recent honesty work. Different workers reported "success" via different fields, different conventions, different placeholder behaviour. The Sync Observatory's classifier work consolidated how truth got determined across the corpus.
  • Identity conventions in ops-db. Four different ways of saying "who is the user" — email-keyed, UUID-keyed, prefix-keyed, randomblob-keyed. Each accreted as a new feature was built without reference to what came before. IDENTITY-MODEL-RATIONALISATION-01 is the pending consolidation.
  • Twilio references in interaction_log. Direct field names baked into the schema rather than abstracted behind a messaging interface. The very thing ADR-014 + ADR-015 establishes the discipline against.
  • stats-cache writing tga_snapshots into the same DB as the mirror. A derivation output (Layer 2) landing in the same substrate as Layer 1 because no architectural separation forced it elsewhere.

These are the same pattern at different scales: code attached at the point of immediate use, never consolidated into a form that subsequent similar needs could consume.

Decision. When the same shape of work appears more than once in the codebase, the second instance is the trigger for consolidating into a shared module rather than copying the pattern again. The shared module becomes substrate-shared infrastructure; subsequent instances consume it rather than reimplementing.

The trigger is two instances. First instance is permissible as feature work — at one occurrence, the pattern has not yet been demonstrated to be recurring, and consolidation is premature abstraction. Second instance is the warning sign: the pattern is recurring; either consolidate now or accept that the third instance will be built against ad-hoc precedent, with all the substrate-coherence costs that implies.

If the answer at the second instance is "no, these are not really the same pattern, the surface similarity is misleading," that judgment is made deliberately and named. Not every similar-looking pattern needs consolidation; only the ones that will recur.

Consequences. - Every new feature involving a recurring substrate operation is evaluated against existing patterns before implementation begins. If a shared module exists, the feature consumes it; if not, the question "should we consolidate now?" is asked deliberately. - Briefs that introduce a second instance of a pattern include the consolidation as part of their scope, not as deferred follow-up work. Deferred consolidation almost never happens; consolidation happens at the moment the recurrence becomes visible. - Existing substrate that violates this principle is identified via audit and consolidated via dedicated briefs (e.g. IRSL pattern was the consolidation for sync logging; future analogous briefs consolidate other patterns). - This ADR is what makes ADR-009 (deliberate client-file attachments) and ADR-015 (full-API-wrapper for external services) instances of a general principle rather than isolated rules. Both are modular-consolidation in different domains. - A subtle failure mode is over-modularising: abstracting things that look similar at first glance but don't actually share a useful underlying form. This ADR explicitly accepts that risk by requiring two instances as the trigger; one instance is too few to know whether the pattern recurs, and waiting for three is too many because the third instance is built against the wrong precedent. - This ADR is foundational for substrate maintainability at three-person scale. A larger team can carry some pattern duplication through human memory; three people cannot. The substrate has to broadcast its own coherent patterns, and consolidation is what produces coherent patterns to broadcast.

See also ADR-009 (client-file attachments as instance), ADR-015 (external service integration as instance), ADR-017 (bus pattern as related principle for flows), client-spine.md Section 9.


ADR-017 — The in-and-out bus pattern for all flows

Status: Accepted Date: 2026-05-26

Context. RTOpacks' substrate has many flows: inbound webhooks (from Stripe, GitHub, ASQA notifications, scheduled triggers), outbound API calls (to TGA, yourcareer, ABS, Cellcast, payment processors, email providers), internal worker-to-worker messages (between sync workers, between modules), and scheduled task firings (cron-driven sync runs, periodic recomputes, scheduled exports).

Each of these flows could be implemented per-consumer — webhook handler attached to whichever worker happens to receive it, outbound call written inline wherever it's needed, worker-to-worker calls made via whatever mechanism the writer reaches for. This is the conventional engineering posture for small systems, and it produces predictable results: flows scatter across the codebase, instrumentation discipline varies by author, debugging requires tracing through whatever ad-hoc structure each flow happens to use.

For RTOpacks specifically, with a substrate-as-moat thesis and three-person operations, scattered flows produce three operational failure modes:

  • Findability fails. When something breaks, the broken flow's location is not immediately clear. "Did the Cellcast webhook fire? Where is that handler? Which worker owns it?" Each answer is a separate code search.
  • Observability fails. Per-worker telemetry produces inconsistent metrics, inconsistent logging, inconsistent error reporting. Cross-flow analysis ("what are all the outbound calls failing today") requires reconstructing data that was never instrumented uniformly.
  • Replaceability fails. When a flow needs to change — different provider, different routing, different protocol — the change cascades through every consumer that holds the flow's logic inline.

Decision. Every flow of data into and out of RTOpacks passes through a known, named, observable structure. These structures are called buses in the architectural sense — they are centralised points that handle one category of flow, route appropriately to consumers, and provide consistent instrumentation and observability.

Specifically:

  • Inbound buses. Webhooks, scheduled triggers, and external pushes route through named inbound bus structures. Each inbound bus knows what types of messages it receives, what consumers handle them, and how to log and instrument them consistently.
  • Outbound buses. External API calls (to providers like Cellcast, TGA, payment processors) route through named outbound bus structures, layered on top of the per-provider wrappers from ADR-015. The wrapper holds provider-specific contract; the bus holds the consistent observability layer.
  • Internal buses. Worker-to-worker communication routes through named internal bus structures rather than ad-hoc direct calls. This is especially important for cross-module communication where the consumer and producer may evolve independently.
  • Scheduled buses. Cron-driven and time-triggered firings route through a named scheduling bus that provides consistent run-status logging (per the IRSL pattern), retry behaviour, and failure handling.

The bus pattern does not require a literal message-queue infrastructure (though some buses may be implemented that way). It requires every flow to have a named, traceable path through known infrastructure, with consistent instrumentation properties applied uniformly.

Consequences. - Findability: every flow has a known location. "Where is the Cellcast inbound webhook handler" has a single answer that doesn't require code search. - Observability: instrumentation is applied at the bus level, not per-consumer. Adding a metric, a log field, or a trace span happens once at the bus and propagates to all consumers. - Replaceability: provider swaps, protocol changes, and routing changes happen at the bus or wrapper level. Consumers stay unchanged. - Naming: every bus has a canonical name that broadcasts its function (e.g. tga-mirror-ingest-bus, sms-outbound-bus, webhook-inbound-bus). The naming convention follows the architectural cartography convention (ADR-018). - Current implementation note: the existing substrate has many flows that do not yet route through named buses. Bringing flows under bus discipline is the work of dedicated downstream briefs, not a single mass refactor. The principle applies to all new flow work immediately; existing flows are migrated as their owners are touched for other reasons. - This ADR composes with ADR-015 (external service wrappers) and ADR-016 (modular consolidation). The wrappers handle per-provider contract; the buses handle cross-flow observability; consolidation handles pattern recurrence. Together they produce a substrate where flows are coherent, named, and traceable.

See client-spine.md Section 9, ADR-016 (modular consolidation), ADR-018 (cartography for visualisation of buses).


ADR-018 — Architectural cartography is a canonical artefact

Status: Accepted Date: 2026-05-26

Context. The architectural disciplines articulated across this doc and the spine doc produce a substrate composed of named, modular units (per ADR-016) connected by named, traceable flows (per ADR-017). Without a way to view that substrate at a glance, the disciplines hold the substrate together but do not make it operationally legible. A new conversation between Tim and Claude about substrate work requires either both parties to hold the same map in their heads, or extensive back-and-forth to establish shared reference.

A canonical visual map of the substrate solves this problem. It also serves three additional purposes:

  • Orientation for new context. When future Claude opens a session, the map orients far faster than prose ever could. "What does the substrate look like" has an immediate visual answer.
  • Dependency tracing. When Alex needs to understand what depends on a piece of substrate before changing it, the map shows the dependency graph rather than requiring reconstruction.
  • Substrate self-documentation. The map IS part of the substrate. As the substrate evolves, the map evolves alongside it, so the substrate carries its own current documentation rather than depending on prose that may have drifted.

Decision. RTOpacks maintains a canonical architectural map of the substrate as a first-class artefact alongside the spine doc and the ADR log. The map is hierarchical (multiple levels of resolution from 10,000-foot system view down to component-level detail), version-controlled, and updated alongside the substrate.

Tool choice: Mermaid as primary format. Mermaid is chosen because:

  • Renders natively in mkdocs (so the map appears on docs.rtopacks.com.au alongside the prose docs)
  • Lives in version control as text
  • Diffs cleanly in git
  • Readable and writable by Claude and Alex without special tooling
  • Sufficient expressivity for system maps, flow diagrams, and dependency graphs at the scale RTOpacks needs

If Mermaid hits expressivity ceilings at some specific level (e.g. very dense component-level maps may need a different format), alternatives are evaluated for that specific level. Mermaid is the default; departures from default require named reasoning.

Hierarchy. The map has multiple resolutions:

  • System level (10,000-foot view). Major systems and their major flows. Shows things like "Sync substrate," "Customer-facing surface," "Internal API," "Identity layer," with the principal flows between them.
  • Module level. Each major system expanded to show its modules, internal flows, and external integrations.
  • Component level. Each module expanded to show concrete bindings — databases, KV namespaces, R2 buckets, specific API endpoints, worker-to-worker calls.

Each level is canonical at its own resolution and references the levels above and below. A reader can zoom in or out as needed.

Naming convention. Components on the map carry names following the shape <scope-prefix>-<domain-or-module>-<function>-<artefact-type>-<environment> with each segment serving a distinct semantic role.

Scope prefix — three categories: rtopacks-*, rto-*, or no prefix (utility). The leading prefix (or its deliberate absence) encodes the artefact's architectural ownership semantics. This is not a brand sticker; it is a meaningful architectural fact broadcast by the name itself.

  • rtopacks-* — platform-owned. Function serves RTOpacks-operator purposes. Substrate ingestion workers, sync infrastructure, external service wrappers, operational tools, admin surfaces. Things RTOpacks the operator runs for its own purposes; clients never interact with them.
  • rto-* — client-facing. Function reaches client outcomes directly. Customer-facing surfaces, client-data databases (Studio, People, Documents data), client-workflow workers. Things the RTO client experiences, holds, or uses; the function's direct outcome is a client outcome.
  • No prefix — utility infrastructure. Function is self-describing; ownership is irrelevant because the artefact serves all sides as substrate-utility, like water or electricity to a building. The artefact's name fully describes its function without ownership inference required. Example: internal-api-worker-prod.

The determination test. A new artefact's category is determined by applying three questions in order:

  1. Q1 — Is the artefact's name fully self-describing such that a reader who knows nothing about RTOpacks could understand what it does from its name alone? If yes, proceed to Q1b. If no, the artefact takes a scope prefix; proceed to Q2.

  2. Q1b — Is the artefact's behaviour environment-neutral? "Environment-neutral" means the artefact does the same thing on the prod side of the Mandarin and on the dev side, with no asymmetric bridging to environment-specific external substrates. Both Q1 AND Q1b must hold for the artefact to qualify as utility. If yes, the artefact is utility infrastructure (no scope prefix). If no — the artefact bridges across the Mandarin to different external substrates per environment (sandbox vs live external services, dev-only data sources, environment-specific upstream endpoints) — it does not qualify as utility regardless of name self-description; proceed to Q2.

Examples of Q1b passing: internal-api (same mediation logic on both sides; doesn't reach external substrates), webhook-inbound-bus (webhook routing is the same regardless of which side), geocoder (geocoding produces the same outputs from the same inputs; different API key per environment is configuration, not behaviour). Examples of Q1b failing: quickbooks-reconcile (dev connects to QuickBooks sandbox, prod connects to QuickBooks live — different external substrates), any stripe-* integration (dev = test mode, prod = live mode — different external substrates). Vendor brand names (QuickBooks, Stripe) resolve to full words in canonical names; informal short forms like qb are not used.

  1. Q2 — Otherwise, does the function reach a client outcome directly? "Directly" means the artefact's primary function terminates in a client outcome, not in a technical outcome that other artefacts transform into client outcomes. If yes, the prefix is rto-*. Examples: rto-site-surface-prod (a client visits the site and takes action; the surface's function terminates in client outcome), rto-studio-db-prod (Studio data is what the client works with; the database's function terminates in holding and returning that data).

  2. Q3 — Otherwise — the prefix is rtopacks-*. Platform-owned by default. The artefact's function serves the platform's needs, with any client benefit reached only via other artefacts' functions. Examples: rtopacks-tga-ingest-worker-prod (ingests TGA data into substrate; client benefit reached only via derived presentations through other artefacts), rtopacks-admin-surface-prod (admin tool used by RTOpacks operators; clients never interact with it), rtopacks-quickbooks-wrapper-prod (wraps QuickBooks API; dev and prod connect to different QuickBooks substrates per ADR-019).

The tests are sequential. The first one that resolves determines the category. This eliminates judgment calls — every artefact's name is mechanically derivable from its function.

Utility is architecturally earned, not name-given. A worker that consumes a wrapper which has encapsulated environment-asymmetry can itself potentially be utility-shaped, because the wrapper has absorbed the asymmetry. This composes elegantly with ADR-015 (full-API wrappers): external asymmetry lives in environment-specific wrappers (rtopacks-quickbooks-wrapper-prod and rtopacks-quickbooks-wrapper-dev), while consumers of the wrapper that operate on stable internal interfaces can themselves achieve environment-neutral behaviour and earn utility status. The architectural path: push external asymmetry to wrapper boundaries; behind those boundaries, environment-neutral utility becomes achievable. See ADR-019 for the Mandarin-enforcement principle that makes this composition possible.

The discipline that protects the utility category from becoming an escape hatch. The utility category exists to honour artefacts whose names genuinely self-describe and whose ownership is architecturally irrelevant. It is not a convenient place to put artefacts whose ownership is unclear or contested.

The high-bar test: "Can a reader who knows nothing about RTOpacks understand what this artefact does from its name alone?" If the answer requires any inference about RTOpacks-specific context, ownership, or domain, the artefact is not utility infrastructure and takes a scope prefix.

  • internal-api passes: "internal" and "api" together fully describe the function — a mediation API used internally.
  • site fails: doesn't tell the reader whose site or what kind; needs rto-* prefix.
  • admin fails: doesn't tell the reader whose admin or what for; needs rtopacks-* prefix.
  • studio-db fails: a reader doesn't know whose studio or what kind of studio; needs rto-* prefix.

When an artefact resists mechanical naming. If applying the three sequential tests does not produce a clean answer — for example, the artefact has multiple functions, some of which reach client outcomes directly and some of which don't — this is itself substrate-quality feedback. The discipline says: investigate whether the artefact should be split into single-function components per ADR-016 (modular consolidation). Mechanical naming is the test of single-function discipline; ambiguous naming surfaces architectural debt as a design issue requiring attention.

This composes with ADR-016 (modular consolidation) and ADR-017 (named bus structures) elegantly. The naming convention's failure to mechanically produce a name is a feature, not a bug — it identifies artefacts that need rework before they accumulate further substrate cost.

Why this discipline matters. Without a deterministic naming convention, every new artefact requires Tim-as-oracle to adjudicate the category. This bottlenecks substrate growth at the rate of Tim's availability and makes the discipline only as strong as one person's continuous involvement. A team-of-three operation cannot tolerate that bottleneck, and a substrate that scales beyond three people definitely cannot.

The convention is deterministic so that Alex, future Claude, future collaborators, and even mechanical tooling can apply it without consulting Tim. Black is black, white is white, utility is utility. The cost of this determinism is articulating the convention sharply enough to remove judgment calls. That articulation is paid once, here. The benefit — substrate-naming that scales beyond Tim's involvement — is permanent.

This discipline matters because it removes a real ambiguity that has cost RTOpacks at multiple points in its history. The same word doing multiple architectural jobs is the failure mode this convention prevents — same principle that retired customer/org/account in favour of client (per client-spine.md Section 3). Names earn their meaning by being used consistently for one specific concept.

Cartography implication. Three categories means three visual treatments on the map. The map legend distinguishes utility nodes (no scope prefix), client-facing nodes (rto-*), and platform-owned nodes (rtopacks-*) via distinct visual cues (colour-coding is the natural default, but specific cartography styling is downstream cartography work). The absence of a prefix is the canonical marker in the artefact's name itself; the visual marker on the map is the supplementary affordance for at-a-glance reading.

Artefact-type suffix vocabulary. The artefact-type segment is drawn from a closed vocabulary:

  • module — a unit of code/functionality, internally cohesive, externally consumed
  • bus — a centralised flow-routing structure (per ADR-017)
  • wrapper — an internal abstraction over an external provider's full API (per ADR-015)
  • proxy — a contract-converter that translates between systems without exposing a full provider API (distinct from wrapper; does not mirror an upstream surface)
  • queue — a Cloudflare Queue or analogous queueing primitive (concrete artefact; may back a bus but is not itself the bus pattern)
  • db — a database (D1 in current substrate)
  • kv — a KV namespace
  • r2 — an R2 bucket
  • worker — a Cloudflare Worker (including cron-triggered workers; the cron is a property of the worker, not a separate artefact type)
  • surface — a customer-facing surface (apps/site, apps/admin, etc.)
  • connector — a binding or junction between two named artefacts (used when the connector itself has a name worth referencing on a map)

Vocabulary extensions are deliberate, not casual. If a substrate-real artefact does not fit cleanly into existing vocabulary, the extension is named explicitly with reasoning, captured in this ADR's history or a successor ADR.

Environment marker. Every named artefact carries an environment suffix marking which side of the Mandarin separation (per HARD SEPARATION RULE in standing-rules.md) it sits on.

  • -prod — live side of the Mandarin. Production substrate, real client data, real RTOpacks operations.
  • -dev — development side of the Mandarin. Development substrate, test data, no client-facing impact.
  • -staging — pre-production verification tier, where used.

Full-word environment tokens are canonical (prod, dev, staging), not single-letter abbreviations. Single letters are visually ambiguous when scanning names; full words are unambiguous because the environment-token vocabulary is a closed set.

The environment marker is a suffix because function-naming reads left-to-right unimpeded with environment arriving last as a qualifier. The Cloudflare-imposed character constraint (Worker and database names allow only alphanumeric characters and hyphens; underscores, slashes, dots, and tildes are disallowed) is met by all elements of this convention.

Worked examples.

Platform-owned (rtopacks-*):

  • rtopacks-tga-ingest-worker-prod — production worker that ingests TGA into the RTOpacks substrate. The function (ingestion) serves platform substrate needs; client benefit reached only via derived presentations through other artefacts.
  • rtopacks-tga-ingest-worker-dev — the dev-side equivalent.
  • rtopacks-sms-cellcast-wrapper-prod — production Cellcast wrapper (per ADR-014). Wraps external provider for platform-wide consumption; not itself client-facing.
  • rtopacks-admin-surface-prod — production admin surface for RTOpacks operators. Clients never interact with it.

Client-facing (rto-*):

  • rto-studio-storage-db-prod — production Studio storage database holding client work. The function (holding and returning Studio data) terminates in a client outcome.
  • rto-site-surface-prod — production client-facing site surface. The function (presenting the site) terminates in a client outcome.
  • rto-workspace-surface-prod — production client workspace at my.rtopacks.com.au. The function (hosting the client's workflow environment) terminates in a client outcome.
  • rto-radar-worker-prod — production Radar worker (RTO intelligence). The function reaches client outcomes directly through Radar's client-facing presentations.

Utility (no scope prefix):

  • internal-api-worker-prod — production internal API worker. The words "internal" and "api" together fully describe the function — a mediation API used internally. Ownership is architecturally irrelevant; consumed by platform-owned and client-facing artefacts alike.
  • webhook-inbound-bus-prod (when the bus pattern lands per ADR-017) — production inbound webhook bus. The name fully describes its function; routes inbound webhooks regardless of which side consumes them.
  • outbound-bus-prod (when the bus pattern lands) — production outbound bus. Same property — name self-describes; consumed by both sides.

The utility category is intentionally small. Most artefacts will land in either rtopacks-* or rto-* because most artefact names do not fully self-describe — they need ownership context to be understood. The utility category is reserved for the genuine substrate-utility cases where the name carries its own meaning unaided.

Current substrate alignment. The existing substrate uses rto-* and rtopacks-* inconsistently (databases mostly rto-*, workers mostly rtopacks-*, with no documented rule). Environment markers are mostly absent from current names; environments are separated by Cloudflare account or deployment configuration rather than by name. Adopting this discipline strictly will require renaming work.

The rename has asymmetric substrate cost. Verified per-resource-type rename feasibility against current Cloudflare documentation (2026-05-26, per CANON-VS-ADR-018-RECONCILIATION-01):

Resource type Renamable? Cost
Workers Yes (stable UUID across rename; routes need re-attachment) Cheap
KV namespaces Yes (wrangler kv namespace rename) Cheap
D1 databases No (no rename CLI/API) Create-new-and-migrate-data
R2 buckets No (bucket name in S3 endpoint URL) Create-new-and-copy-objects
Queues No ("queue name cannot be changed after creation") Create-new-and-migrate-consumers
Service bindings Yes (via Worker rename; UUID stable) Cheap

The naming-conformance brief (WORKER-AND-DB-NAMING-CONFORMANCE-01) reflects this asymmetry with phased sequencing:

  • Phase 1 (immediate when brief drips): Worker and KV renames. Both renamable cheaply; fast; high architectural-visibility value.
  • Phase 2 (opportunistic): D1 / R2 / Queue renames bundled into migration briefs that are otherwise running (OPS-DB split, engine-db-oc rename per ADR-008 cleanup, any future schema migrations or content migrations that touch resource identity).
  • Phase 3 (deliberate): Remaining D1 / R2 / Queue renames that lack a natural migration vehicle. Dedicated brief if and when the cost-benefit justifies; otherwise deferred indefinitely.

This sequencing respects substrate cost without weakening the discipline. The convention applies; the execution sequencing is pragmatic. The verification table is point-in-time; if Cloudflare adds rename APIs for additional resource types, Phase 1 expands accordingly. Substrate-specific rename details and CF documentation references live in infrastructure/cloudflare-naming-canon.md.

The convention applies to all new artefacts from this point forward immediately. Existing-substrate rename happens per the phased schedule.

The cartography v2 map labels nodes per the new discipline even where current substrate names differ. The map shows target-state names with current-state names noted alongside where they diverge, making the rename surface itself a piece of visible substrate work rather than hidden tech debt.

The convention is canonical from this point forward. Refinement happens against concrete examples as the cartography work proceeds; substantive changes to the convention require explicit ADR updates with named reasoning.

Living document. The map is maintained alongside the substrate. When new components are added, named, or relocated, the map updates in the same commit as the substrate change. This is enforced by discipline (the briefs that change substrate also update the map) rather than by tooling, though tooling support may be added later if discipline alone proves insufficient.

Consequences. - The map lives at a canonical path in the repo, rendered into docs.rtopacks.com.au as part of the docs site. Exact path is part of the prelim cartography work. - A first prelim cartography brief produces the v1 10,000-foot map of the substrate as it exists today, with working names per the proposed taxonomy. This v1 is refined into v2 (and beyond) as substrate work proceeds. - Every brief that touches substrate considers whether the map needs to update as part of the brief's scope. If yes, the map update is part of the brief deliverable. - New components are named at design time, with names following the convention. Names are not added after implementation as an afterthought. - The naming convention itself is a canonical commitment. If a component name does not fit the convention, either the component is renamed or the convention is extended deliberately (with named reasoning in this ADR's history or a successor ADR). - This ADR is what makes the in-and-out bus pattern (ADR-017) operationally visible. A bus that exists in code but is not on the map is invisible to anyone who hasn't read the code. A bus on the map is immediately visible to everyone. - This ADR is what makes the modular-consolidation principle (ADR-016) operationally enforceable. A module that exists in code but is not on the map will be reinvented by anyone who doesn't know it exists. A module on the map gets reused because it's visible.

This ADR closes the loop on the three-principle composition: modules produce nameable units (ADR-016); buses produce traceable flows (ADR-017); cartography makes both legible at a glance (ADR-018). A three-person operation can operate substrate of meaningful complexity if all three principles hold.

See client-spine.md Section 9, ADR-016 (modular consolidation), ADR-017 (bus pattern), the cartography artefact itself once it lands.


ADR-019 — Mandarin enforcement at the credential-surface layer

Status: Accepted Date: 2026-05-26

Context. External service integrations (per ADR-014 for Cellcast, future Stripe/QuickBooks/Twilio rationalisation, any future integration) bridge across the Mandarin separation to different external substrates per environment: sandbox-QuickBooks vs live-QuickBooks, Stripe test mode vs live mode, dev-API-keys vs prod-API-keys, and so on. The architectural question is where this asymmetry lives.

Two viable approaches:

  • Per-environment code branches. Wrapper code includes conditional logic like if env === 'prod' then live_endpoint else sandbox_endpoint. Asymmetry lives in the code path. Different code runs in prod vs dev.
  • Environment-neutral code with environment-specific credential bindings. Wrapper code is identical across both sides. Asymmetry lives entirely in what credentials and configuration get loaded at runtime. The same code, configured twice.

Per-environment code branches produce drift, surprises, and silent inconsistency at scale. They also make utility-shaped artefacts impossible — any consumer of an asymmetric-code wrapper inherits the asymmetry. The alternative — environment-neutral code with credential-layer separation — preserves code reuse, prevents behavioural drift, and allows downstream consumers to be utility-shaped per ADR-018.

Decision. The Mandarin separation for external service integrations is enforced at the credential-surface layer, not at the code-branch layer. Wrapper code (per ADR-015) is environment-neutral. Environment selection happens at credential and configuration loading time. The substrate enforces the boundary via Cloudflare-secret scoping; the admin surface enforces operator-side discipline; sole-operator awareness backs the architecture as secondary safety net.

The principle in plain words: the same wrapper code runs on both sides of the Mandarin, configured with different credentials. Like a machine gun chambered for two different bullet types: same mechanism, different ammunition, different outcomes. The bullet selection happens at loading time, via the admin surface, not at code-execution time.

The four enforcement layers, in order of strength.

  1. DNS-level enforcement. The dev and prod admin surfaces live on different top-level domains: admin.rtopacks.dev for dev, admin.rtopacks.com.au for prod. The operating system resolves these to different places. There is no subdomain-pattern that could be mistyped to cross the boundary; the TLDs are visibly and unmistakably different. This is the strongest visual broadcasting available.

  2. Substrate-level enforcement. Cloudflare Workers Secrets are scoped per environment. Dev workers have permission to read dev secrets; prod workers have permission to read prod secrets; cross-side access is structurally impossible because Cloudflare Access policies do not grant it. If an operator accidentally pastes a live token into the dev admin surface, the write either fails or writes to dev secrets where the prod wrapper cannot read it. The substrate enforces the boundary; the application layer trusts the substrate.

  3. Admin-surface enforcement. The admin surfaces are designed to broadcast which side they operate on through visual cues (banner colour, header treatment, persistent UI indicators) in addition to the URL itself. The admin surface is the operator's primary affordance for credential management; the surface's visible identity is the operator's primary signal of which side they are operating on.

  4. Operator discipline. Sole-operator awareness (Tim, with possible future Jimmy or other admin) of the consequences of misloading credentials. This is the weakest enforcement layer and is intentionally so: it is a secondary safety net, not the primary mechanism. The substrate-level enforcement makes accidental cross-contamination impossible; operator discipline catches the residual edge cases.

The admin surface as canonical external-integration cartography. The admin surface for each side is not merely a credential-management UI. It is the canonical view of which external services that side connects to, what the credential state is, when each was last rotated, and how to roll each one. Operators look at the dev admin to see what dev is connected to; they look at the prod admin to see what prod is connected to. The admin surface IS the human-readable reconciliation view of external integration state per environment.

This has implications for admin-surface design that go beyond ordinary credential management:

  • Per-environment inventory. The admin surface lists every external service that side connects to. New integrations appear when their wrapper is deployed; retired integrations disappear when their wrapper is removed. The list is substrate-derived, not maintained by hand.
  • State visibility. Current token, last-rotated timestamp, expiry (where known), operator who last rotated. Not deep telemetry; just the small set of facts operators need at a glance.
  • Rotation guidance. Each entry includes brief operator-actionable text: "to rotate, go to the Cellcast dashboard, regenerate the API key, paste it here." The guidance lives next to the credential it applies to, not in a separate runbook.
  • Operational frequency awareness. Dev tokens roll often (dev exposure happens in normal work); prod tokens roll rarely. The admin surface is optimised differently per side: dev admin is optimised for quick rotation, prod admin is optimised for rare-but-careful rotation with confirmation flows and audit-trail visibility.

Consequences.

  • Wrapper code (per ADR-015) is environment-neutral by construction. No if env === 'prod' branches anywhere in wrapper code. Provider endpoints, credentials, and behaviour are loaded from environment-specific configuration; the wrapper does not know which environment it is in.
  • Workers that consume environment-neutral wrappers can themselves be utility-shaped (per ADR-018 Q1b). The architectural path to utility runs through wrapper encapsulation of asymmetry.
  • The admin surfaces (rtopacks-admin-surface-prod and rtopacks-admin-surface-dev) are first-class enforcement infrastructure, not secondary tooling. ADMIN-AUTH-MODEL-RECONCILIATION-01 (or whichever brief touches admin-surface design) treats credential cartography as a primary concern, not as an afterthought.
  • The current substrate posture assumes sole-operator-with-substrate-backup. If the team grows to where multiple humans hold admin write access, this ADR is revisited with appropriate access-control workflows (role-based gating, change-approval workflows, four-eyes for sensitive rotations). Until then, the substrate-level enforcement plus operator-discipline-as-safety-net is the appropriate posture.
  • Audit trail of credential changes (which token loaded, by whom, into which side, when) is operational machinery that wants its own discipline brief at some future point. Not blocking for current operations; useful for future remediation if cross-contamination ever happens.
  • This ADR is what makes ADR-018's Q1b test architecturally achievable. Without credential-layer Mandarin enforcement, environment-neutral behaviour would be impossible and the utility category would collapse to artefacts that don't touch external substrates at all (a very small set). With this ADR, the utility category can be expanded by deliberate architectural work (wrapper-encapsulation of asymmetry).

See ADR-014 (Cellcast as canonical first wrapper), ADR-015 (full-API wrapper pattern), ADR-018 (naming convention, with Q1b grounded in this ADR's environment-neutrality property).


ADR-020 — The access-control model has three operating tiers

Status: Accepted Date: 2026-05-27

Context. RTOpacks needs a coherent access-control model that names who can do what, against whose substrate, with what authority. The current substrate has four unresolved identity conventions (per IDENTITY-MODEL-RATIONALISATION-01's scope) and no canonical access-control articulation. ADR-007 establishes user/credential separation at the schema level; ADR-008 establishes that there is no platform tier above clients; ADR-012 establishes signup-and-admin-authority decoupling. These three ADRs compose a partial answer but do not name the operating tiers explicitly. Without an explicit tier model, downstream identity rationalisation work has no target shape to rationalise toward, and the access-control behaviour accretes per-feature rather than expressing a coherent design.

The model needs to handle: customer-side administration and use (the RTOs themselves), operator-side support (the three-person team running the substrate), the variable shapes of customer organisations (one-person RTOs through to large multi-staff TAFEs), and future extensions (multi-administrator delegation, role differentiation among administrators).

Decision. RTOpacks operates three access-control tiers: an operator tier (T3), a client administrator tier (T4), and a client user tier (T4A). The numbering preserves continuity with the historical UCCA conceptual mapping from which the substrate inherits some of its lexical patterns; the numbers are not load-bearing but the structural distinctions are.

T3 — Operator tier. The three-person team operating the substrate (Tim, Alex, Claude). Holds super-user authority across all client substrates. Functions: substrate operation, infrastructure administration, customer support, incident response. T3 is the highest tier; there is no platform tier above it (per ADR-008). T3 does not have a commercial relationship to client substrates — operators are not paying customers of their own platform.

T4 — Client administrator tier. One or more users per client file holding administrative authority over that client's substrate. Functions: user invitation and management, role assignment among other users, plan and billing control, view of all client substrate state. T4 is established via the ADR-012 signup flow (asynchronous CEO-notification path for the first administrator; T4 administrators can subsequently invite further administrators, subject to the multi-administrator extension below). In the minimum-viable shape, one T4 administrator per client file. Schema and code are designed to accommodate multiple T4 administrators per client file without redesign — co-administration with role-differentiated administrators (Finance Admin, Education Admin, Compliance Admin, etc.) is designed-in but not built in initial implementation; the schema and code accommodate the extension when its case is real.

T4A — Client user tier. Users attached to a client file who do operational work within it but do not hold administrative authority over the client substrate. Functions: module use (Studio session work, People register maintenance, Radar interpretation, Record evidence handling, etc.), per-user state, per-user audit trail. T4A users are invited by T4 administrators; their permissions are a subset of what the T4 administrator grants. Different T4A users may have different permission sets within the same client substrate (a trainer's permissions differ from a compliance officer's permissions); permission shape lives in the module specs and is governed by ADR-009's deliberate-attachment discipline.

T4 and T4A are separable concepts, not separable humans. A T4 administrator is a role, not a user identity. The same human can hold both a T4 role and a T4A user identity simultaneously; in fact, this is the expected pattern. The T4 administrator role is the structural authority position; the T4A user identity is the countable seat that does operational work and against which plan entitlements consume. In a one-person RTO, the same human wears both hats — the architecture does not change, only the role-binding distribution does. Multi-person RTOs have one human in the T4 administrator role (or more, under the multi-administrator extension) and multiple humans in T4A user roles, with the T4 administrator typically also being one of the T4A users.

Consequences.

  • IDENTITY-MODEL-RATIONALISATION-01 now has a target shape to rationalise toward. The four current identity conventions resolve into: T4 administrator role-attachment, T4A user identity, with the substrate distinguishing role from identity at the schema level.
  • Module specs include their T4A permission model as a first-class section (composing with ADR-009's deliberate-attachment requirement). Each module names which T4A permission shapes it recognises and what each permits.
  • T4 administrators have implicit view-everything authority within their client substrate; explicit permission gates exist for actions (invite user, assign role, change plan, etc.) but not for read access within the client boundary.
  • T4A permissions are positive grants from the T4 administrator's authority pool — a T4 administrator cannot grant a T4A user a permission the T4 administrator does not hold. (This composes with the multi-administrator extension when it lands: a Finance Admin can grant finance-related T4A permissions but not compliance-related ones.)
  • Plan entitlements attach to client files (per ADR-021), not to individual T4A users. T4 administrators control plan; T4A users consume seats and resources within the plan envelope.
  • T3 operator access to client substrates is governed by ADR-022 (operator impersonation pattern), not by membership in the client substrate's access model. Operators do not appear in client user lists.
  • T4 administrator visibility into T4A user activity within their own client substrate is a customer-configurable capability, not an architectural commitment. The customer governs the employment-relationship ethics of internal observation through their own configuration; RTOpacks provides the technical capability without legislating how the customer exercises management authority over their own staff. Whether T4A users are notified of administrator observation actions is a customer choice, not an architectural default.
  • Cross-client access is structurally impossible. A T4 administrator of client A has no access whatsoever to client B's substrate. T3 operator access is the only path across client boundaries, and it is gated by the impersonation pattern in ADR-022.

See client-spine.md § 1, § 3, § 7, § 8; ADR-006; ADR-007; ADR-008; ADR-011; ADR-012; ADR-019; pending IDENTITY-MODEL-RATIONALISATION-01.


ADR-021 — Plan, entitlement, and metering are substrate concerns

Status: Accepted Date: 2026-05-27

Context. RTOpacks is commercially activated when paying customers attach to client files via subscription plans. The substrate needs to know about plans (what tier is this client on), entitlements (what does that tier permit), seats (how many T4A users does this client get), and metering (how much compute, ingestion, or other consumable resource has this client used relative to their plan envelope). Identity rationalisation that does not produce a plan-and-entitlement-aware structure produces something that has to be torn apart at commercial activation. The current identity model has no concept of plan attachment, seat-counting, or metering — these have to be designed into the substrate before commercial activation, not retrofitted afterward.

The pay-per-use compute model (similar in shape to LLM-platform tiering — a baseline plan tier includes a compute allocation; consumption beyond that triggers overage billing or plan upgrade) is the working assumption for RTOpacks' commercial model. Specific price-list questions are separate; the substrate shape needs to support this model whatever the prices turn out to be.

Decision. Plan, entitlement, and metering are substrate-level concerns with their own architectural commitments.

The plan attaches to the client file. Not to T4 administrators, not to T4A users. The client file is the commercial unit. A client has one plan at a time. T4 administrators control plan selection, upgrades, downgrades, and cancellation; plan state propagates to all T4A users attached to that client file.

Entitlements flow from plan to client to module access. A plan defines which modules the client has access to, what limits apply within those modules (seats, content generation counts, ingestion frequency, storage), and what overage behaviour kicks in when limits are reached. Module specs declare what they require from a plan to operate; the plan database is the authoritative source of "is this client entitled to this module at this level."

T4 administrator role does not consume a seat. The T4 administrator role is structural; the T4A user identity is countable. A plan with five T4A seats means five users can do operational work in that client substrate; the T4 administrator role exists independent of seat consumption. If the human who is the T4 administrator also wants to do operational work, they create a T4A user identity for themselves and consume one of the five seats. This separation is deliberate: it lets administrative oversight survive scenarios where all T4A seats are reassigned, paused, or in use.

Metering is substrate infrastructure, not a billing feature. A counter exists in substrate for each consumable resource (compute, ingestion runs, content generation calls, storage growth, others as they emerge). The counter accumulates per client. The plan defines the threshold at which overage kicks in. Below the threshold, consumption is invisible to the client. At and above the threshold, the substrate surfaces consumption state to the T4 administrator (with appropriate notice — see "consumption transparency" below) and routes overage to either pay-per-use billing or plan-upgrade prompts depending on plan configuration.

Consumption transparency. The T4 administrator can see consumption state for their client at any time. Approaching-threshold and overage states are surfaced through the admin surface, not buried in invoice details after the fact. The substrate does not surprise the customer with bills they could not have seen coming.

Consequences.

  • The substrate gains a plan database (or its functional equivalent) as a first-class entity, attached to client files. CREDENTIAL-PROVIDER-DECISION-01 and identity rationalisation work compose with this.
  • Module specs declare their plan-and-entitlement requirements explicitly. ADR-009's deliberate-attachment discipline extends to plan attachment: every module names what it requires from a plan to operate.
  • A pricing model exists separate from this ADR (which addresses architecture, not commercial pricing). The architectural shape supports whatever pricing model emerges; the pricing model can change without substrate redesign.
  • Audit trail spans plan changes. Plan upgrades, downgrades, lapses, resumptions, and overage events are all audit-trailed in the substrate. T4 administrators can see plan history for their client; T3 operators can see plan history across clients for support and finance purposes.
  • The plan/entitlement/metering substrate composes with ADR-023 (graceful degradation across subscription state). The substrate knows what the client had access to when subscription was active; that knowledge persists across lapse and is available on resumption.
  • Specific pricing decisions are deferred to commercial activation planning. This ADR is the architectural commitment to the model shape, not to the prices.

See client-spine.md § 1, § 3; ADR-008; ADR-009; ADR-010; ADR-011; ADR-020; ADR-023.


ADR-022 — Operator support uses the impersonation pattern

Status: Accepted Date: 2026-05-27

Context. T3 operators (the three-person team running RTOpacks) need to be able to enter T4 client substrates for support purposes — diagnosing customer-reported issues, observing what the customer is actually seeing, reproducing problems, validating that a fix has taken effect. The naive approach (log in as the customer using their credentials) is unacceptable on multiple grounds: it requires the customer to share credentials, it pollutes the customer's own audit trail with operator actions attributed to the customer, it creates a security surface where operator credential compromise becomes customer credential compromise, and it offers no clean exit pattern.

A separate operator-tier surface that views client substrate data without entering the client surface is insufficient — it does not let the operator see what the customer sees, which is often essential for diagnosing UX or workflow issues that are not visible in raw data.

The pattern needed has historical precedent in the UCCA architectural lineage (the "Ortho" pattern — colloquially described as monkey-down-the-ladder, monkey-up-the-ladder-and-close-the-hatch). The substantive properties of that pattern are worth canonicalising as RTOpacks' approach.

Decision. T3 operator support of T4 client substrates uses the operator impersonation pattern.

Mechanics. A T3 operator initiates an impersonation session targeting a specific T4 client substrate, optionally targeting a specific T4A user identity within that substrate. The substrate constructs a view-as-if-customer surface that mimics what the customer would see, with full read access to substrate state and limited (typically read-only by default; write capability gated by deliberate escalation) action capability. The operator's actions during the session are attributed to the operator-as-operator, not to the customer; the customer's own audit trail is not polluted by operator activity, and the operator's actions are visible in the operator-side audit trail with full context.

Session boundary. The operator enters the session deliberately, works within it, and exits deliberately. Entry is logged; exit is logged. The operator does not appear as a logged-in user in the customer's surface. The customer's experience is unaffected by the operator session — the customer is not notified, and the customer-facing surface does not surface operator activity.

Hatch closure. When the operator exits the session, the surface closes cleanly — no lingering operator access, no residual T3 authority in the customer's surface, no reverse-traversal possibility. The customer's surface is, after the operator exits, indistinguishable from what it would have been if the operator had not entered.

Comprehensive operator-side audit logging. Every T3 access to a T4 client substrate is logged with reason, actor, duration, and scope. The logs exist for three operator-side purposes: maintaining operator integrity (machines cannot keep machines honest; the audit trail enables human review of operator activity, including by external third parties if RTOpacks is ever called to demonstrate operator conduct), preserving evidence (if RTOpacks is ever called to demonstrate what happened in a customer substrate, the logs are the substantive record of actual events rather than recollection), and operational diagnostics (repeated operator access to the same client substrate for the same shape of issue surfaces operational hotspots worth addressing systemically).

The logs are operator-side artefacts. They are not part of the customer-facing surface. The architectural commitment is that operator activity is comprehensively logged at the moment of access, not that the customer is notified of operator activity. The substantive protection against covert misuse is the existence of the audit trail and the discipline of reviewing it, not customer-facing real-time visibility.

The architectural prohibition is operator activity that bypasses logging, not operator activity that is unnotified to the customer. Routine operator presence in customer substrates is a legitimate part of operating the platform. The customer signed up for software that requires operators to function. Treating routine operator access as a thing requiring per-incident customer notification would be performative — it would generate friction on legitimate operations without preventing the harms it claims to prevent. The protection that matters is structural: every operator action is captured at the moment of access, with reason, in logs the operator cannot bypass.

Legitimate future scenarios where customer-facing notification becomes a configurable overlay (enterprise compliance requirements, regulatory mandates, customer-requested transparency features) are addressed by deliberate ADR articulation at that point, not by ad-hoc backdoor implementation. The default architecture is comprehensive operator-side logging without customer-facing notification overlay; configurable extensions exist as deliberate additions per case.

Consequences.

  • A canonical impersonation-session substrate primitive exists, used by operator support and by no other code path. Authentication into the impersonation primitive is via T3 operator credentials and substrate-internal authorisation; customer credentials are never used.
  • Operator activity is comprehensively logged at the substrate level. The logs are operator-side artefacts (not surfaced to T4 administrators in the customer-facing admin surface) and serve operator integrity, evidentiary preservation, and operational diagnostics. They are reviewable by Tim, by Alex when authorised, by external third parties if legal or regulatory review is ever required.
  • The pattern composes with ADR-019 (Mandarin enforcement). Operator impersonation operates on the prod side of the Mandarin for prod customer support; on the dev side for dev work. There is no cross-Mandarin operator access; a prod-side incident cannot be diagnosed by impersonating a dev session.
  • The pattern composes with the disposition articulated in client-spine.md § 1: operator access to customer substrates is governed by traceability and integrity discipline, not by performative customer notification. Operator capability exists for legitimate operation; covert misuse is structurally prevented by logs that operator activity cannot bypass; routine operations are not theatrical notification events.
  • Future extensions (e.g. multi-operator co-session for complex debugging, read-only versus write-permitted session modes, customer-facing notification overlays where legitimate cases emerge) are extensions of the impersonation primitive, articulated as deliberate ADR additions when their case is real.

See client-spine.md § 1; ADR-008; ADR-019; ADR-020.


ADR-023 — Graceful degradation across subscription state

Status: Accepted Date: 2026-05-27

Context. Customers' commercial relationship to RTOpacks varies over time — they subscribe, they may lapse (cancellation, payment failure, deliberate pause), they may resume. The naive subscription-lapse handling pattern in SaaS is hard lockout: subscription ends, the customer cannot log in, their state is invisible to them, returning to the product requires re-onboarding from a position of "I had something and now it's gone." This pattern weaponises the customer's dependence on the substrate and produces measurable retention loss because customers who could not return easily simply do not.

The no-weaponised-lock-in principle articulated in client-spine.md § 1 names this as an architectural failure mode RTOpacks is structurally opposed to. The substrate is sentinel, not jailer. The disposition matters in itself and produces a commercial property: customers who can return easily often do, and customers who experience a soft door rather than a hard wall are measurably more likely to re-subscribe than customers who experience punishment for lapsing.

The architectural shape needed: subscription state changes affect what the customer can do, not what the substrate holds about them.

Decision. RTOpacks supports graceful degradation across subscription state. The substrate retains client state across subscription lapses; the T4 administrator role survives lapse; T4A users deactivate-not-delete; resumption is frictionless.

T4 administrator survives lapse. When a client subscription lapses, the T4 administrator role remains intact. The T4 administrator can log in, see what was there, see consumption state at time of lapse, see what changed about RTOpacks while they were away, and decide whether to resume service. The administrative surface during lapsed state is intentionally different from the active-subscription surface — it shows the preserved state, presents the resumption path, and does not gate the administrator from understanding what they previously had.

T4A users deactivate, not delete. When subscription lapses, T4A users transition to a deactivated state. They cannot log in; they cannot perform actions; the seats they occupied are not consumed. Their identity, their permission assignments, their action history, and their attachment to the client substrate all persist in the substrate. On resumption, T4A users return to their previous state — same permissions, same identity, same history — and resume work without re-onboarding friction.

Substrate state persists. Studio sessions, generated content, audit trails, plan history, consumption records, module-specific state — all of this persists in the substrate across subscription lapses. Lapse does not trigger data deletion. The customer's work is theirs; the substrate continues to hold it whether the customer is currently paying for active access or not. (Long-term retention beyond reasonable bounds is a separate operational question; this ADR addresses the architectural commitment, not unbounded storage at platform cost.)

Resumption is frictionless. A T4 administrator resuming subscription does not re-onboard. They restore plan selection (which may include reviewing current plan tiers if pricing has changed in the meantime), confirm payment, and their substrate is immediately active again with all T4A users, all permissions, all state, all history intact. Resumption is one decision, not a re-signup.

Audit trail spans state changes. Subscription lapse, resumption, deactivation, reactivation are all audit-trailed in the substrate. The T4 administrator can see "the client was lapsed from date X to date Y, resumed by user Z." T3 operators can see lapse-and-resumption history for support and finance purposes.

Consequences.

  • The substrate has explicit deactivated-not-deleted user states. T4A user identities have a lifecycle that includes deactivation as a non-terminal state.
  • T4 administrator surfaces have a designed lapsed-state mode, not just an active-state mode and an error-state mode. The lapsed state is a first-class experience, not an exception handler.
  • Plan substrate (per ADR-021) retains plan history per client across lapses. On resumption, plan history is intact; consumption counters reset or carry forward per plan-specific policy (a question separate from this ADR).
  • Module specs that hold per-user state include deactivation handling. When a T4A user deactivates, their per-user module state persists, and when they reactivate, it is intact. Modules do not have to handle "user has been recreated from scratch"; they handle "user has been dormant."
  • The export-portable output capability (ADR-010) compounds with graceful degradation. Customers who choose to leave permanently can take their work with them; customers who lapse temporarily come back to it intact. Both paths are first-class.
  • The no-weaponised-lock-in principle articulated in client-spine.md § 1 is the substantive reason for this ADR. The architectural choices below — administrator survives, users deactivate-not-delete, state persists, resumption is frictionless — each express that principle structurally.

See client-spine.md § 1; ADR-010; ADR-020; ADR-021.


ADR-024 — Canonical user-identity schema model

Status: Accepted Date: 2026-05-27

Context. The substrate accreted four key conventions (email-keyed / UUID-keyed / prefix-keyed / randomblob-keyed) across two identity-bearing D1 databases (rto-ops-db + rto-workspace-db), with cross-DB duplicate tables, six parallel L3-truth source mechanisms with no synchronisation, an L1-L4 UCCA-lineage tier system that does not match ADR-020's T3/T4/T4A vocabulary, and a tenants table that ADR-008 retired but three tables still reference. IDENTITY-MODEL-RATIONALISATION-01 Phase 1 audit (filed at ops/audits/IDENTITY-SURFACE-AUDIT-01.md) documented this state empirically.

ADR-007 (user/credential separation), ADR-020 (T3/T4/T4A access-control), ADR-021 (plan/entitlement/metering), ADR-022 (operator impersonation), and ADR-023 (graceful degradation) provide architectural principles but do not specify the schema-level shape that honours them. This ADR canonicalises the schema. The full design lives at ops/designs/IDENTITY-MODEL-CANONICAL-01.md; this ADR captures the load-bearing architectural commitments.

Decision. The canonical user-identity schema is three tables plus one auxiliary table plus two reshaped existing tables.

The three core tables.

  • users — UUID-keyed (RFC 4122). One row per human identity. Carries email UNIQUE, display_name, email_verified, client_id FK (NULL for T3 operators; required for T4/T4A), status enum (active/deactivated per ADR-023), INTEGER unixepoch() timestamps. Email change is an UPDATE, not a new row.
  • tier_grants — T3/T4/T4A role attachment. Single canonical source of "what tier is this user?" Replaces six parallel L3-truth sources (CF Access JWT as authority, operator_roles, access_allowlist as tier grant, user_tenant_roles, admin_sessions.tier hardcoded, orgs.billing_tier='internal' shortcut). UNIQUE(user_id, tier, client_id) permits T4 + T4A on same user_id + client_id (the ADR-020 separability between role and seat); permits multiple T4 grants on same client_id (the ADR-020 multi-administrator extension designed-in).
  • credentials — provider-opaque reference per ADR-007 separation. provider TEXT column accommodates CREDENTIAL-PROVIDER-DECISION-01's eventual provider mix without schema migration. metadata TEXT (JSON) carries provider-specific shape (see EXT-API convention below).

The auxiliary table.

  • magic_link_allowlist — renamed and semantically narrowed from access_allowlist. Gates magic-link issuance only; does not confer tier.

Two reshaped existing tables.

  • impersonation_tokens (ADR-022 implementation, already present) — tenant_id renamed to client_id per ADR-008; target_role narrowed to target_tier CHECK enum (T4/T4A only — impersonating another T3 is meaningless); new reason column per ADR-022 audit-trail discipline.
  • portal_invitestenant_id renamed to client_id. Otherwise unchanged.

Issuance gates vs tier grants — architectural distinction.

Two stages of the auth flow are now distinct first-class concerns:

  • Issuance gates — pre-authentication. Determine whether a credential request is honoured at all. Example: magic_link_allowlist decides whether to mint a magic-link token for a requested email. Membership in an issuance gate does NOT confer any tier.
  • Tier grants — post-authentication. Determine what authority an authenticated user holds. Example: tier_grants(tier='T3') declares operator authority for a successfully-authenticated user.

The substrate previously conflated these (per the audit's six L3-truth source finding — access_allowlist membership was implicitly an L3 grant). The canonical model separates them. Future briefs that propose new identity-adjacent tables answer explicitly: is this an issuance gate, a tier grant, or something else? Naming and placement follow that classification.

Provider-specific metadata documented in EXT-API reference docs.

The credentials.metadata TEXT column is opaque JSON to keep the schema provider-agnostic. Provider-specific shapes (passkey aaguid + public_key + sign_count; magic-link expiry + ip; CF Access policy id + auth_method) are documented in the EXT-API reference doc for each provider per the EXT-API RULE in standing-rules. This couples the credential schema to the existing EXT-API discipline rather than creating a parallel documentation surface.

Key convention. UUID-keyed (RFC 4122, 36-char hyphenated) is canonical. Generated via crypto.randomUUID() at signup. Reasons against the three alternatives are documented in the design doc (ops/designs/IDENTITY-MODEL-CANONICAL-01.md § 2.2).

Tier vocabulary. L1-L4 retires; T3/T4/T4A canonical. L1 + L2 enum values have no equivalent (T3 is the highest tier per ADR-020). The zero-UUID admin's L1 row migrates to T3.

Zero-UUID admin disposition. The row 00000000-0000-0000-0000-000000000001 is preserved. The UUID is valid RFC 4122; nothing structurally privileges its all-zeros-except-last-octet pattern. The historical "zero-UUID = platform anchor" convention survives as substrate observation, not runtime invariant. Code paths that depend on the literal value migrate to tier_grants(tier='T3') lookup.

Consequences.

  • Six L3-truth sources collapse to one (tier_grants). CF Access remains the DNS-level authentication gate per ADR-019 but is no longer also a tier authority.
  • Per-row migration strategy (not per-table) — the audit found mixed conventions within identity surfaces; each row normalises independently.
  • Eight tables retire across both DBs: operator_roles, user_tenant_roles, admin_sessions, tenants, both copies of magic_tokens, both copies of passkey_credentials (migrated to credentials), both copies of users (consolidated). Plus the five ADR-008 orphans via the separate OPS-DB-IDENTITY-ORPHAN-CLEANUP-01 brief.
  • Reconciled 2026-06-15 (PASSKEY-CREDENTIALS-SCHEMA-DIVERGENCE-01). The scheduled passkey_credentials → credentials migration above is moot — the placeholder was dropped, not migrated (its 3 rows were SYSTEM_ACTOR scaffolding with sign_count=0; no real credential data to carry). The canonical credentials table stands present, empty, and zero-writer in identity-db until a real provider is built. Prose now matches bytes. Ledger-drift note: IDENTITY-MODEL-MIGRATION-01.sql had already marked these rows RETIRED while the live passkey_credentials table drifted past its own retirement — present and being written to on the admin enrolment path. A retirement recorded in a migration file is not a retirement executed against the substrate; the drop made the ledger true. Verify the bytes, not the ledger.
  • Two cross-DB duplicate sub-briefs subsume into this design's migration (users + passkey_credentials). Two remain standalone (products + magic_tokens — the new fourth duplicate not previously filed).
  • DB placement deferred to OPS-DB-SPLIT-SHAPE-DECISION-01. The schema is portable across three placement candidates (workspace-db / ops-db / new identity-dedicated DB).
  • ADMIN-AUTH-MODEL-RECONCILIATION-01 composes cleanly — the credentials table accommodates the three possible outcomes for passkey scaffolding (preserve / wire verify / drop).
  • CREDENTIAL-PROVIDER-DECISION-01 composes cleanly — new providers land as new provider values without schema migration.
  • The session shape (apps/workspace/lib/session-types.ts) replaces ucca_layer: 1|2|3|4|4.5 with tier: 'T3'|'T4'|'T4A' + client_id. Code-path update is Phase 3 migration scope.
  • Phase 3 migration is a separate brief (IDENTITY-MODEL-MIGRATION-01). The schema commitment lands here; execution lands there.
  • Cross-DB FK references in this schema's client_id columns are documentation-only per ADR-026. The IMM-01 Phase 2 substrate-reality finding established that D1 cross-DB FK references parse at CREATE TABLE but fail at INSERT time when the referenced table doesn't exist in-DB. The canonical schema at scripts/identity-schema.sql documents the client_idclients(id) relationship as a comment, with referential integrity an application-layer responsibility.

See client-spine.md § 3, § 7, § 8; ADR-007; ADR-008; ADR-012; ADR-019; ADR-020; ADR-021; ADR-022; ADR-023; ADR-026; EXT-API RULE in standing-rules.md; full design at ops/designs/IDENTITY-MODEL-CANONICAL-01.md; audit at ops/audits/IDENTITY-SURFACE-AUDIT-01.md.


ADR-025 — Identity model placement: dedicated identity-db

Status: Accepted Date: 2026-05-27

Context. ADR-024 canonicalised the user-identity schema (three-table model: users + tier_grants + credentials, plus magic_link_allowlist and reshaped impersonation_tokens + portal_invites) but explicitly deferred database placement. The IDENTITY-MODEL-PLACEMENT-DECISION-01 brief evaluated three placement options against substrate state and architectural fitness:

  • Option A — Identity in rto-workspace-db (substrate-state preservation; workspace-db is canonical-by-content today)
  • Option B — Identity in rto-ops-db (substrate-state reversal honouring MANDARIN intent; operator concerns in ops-db)
  • Option C — Identity in new rto-identity-db (first-class identity subsystem)

The decision analysis at ops/decisions/IDENTITY-MODEL-PLACEMENT-ANALYSIS-01.md captured the full trade-off. Substrate-scan corrected the initial cost framing: Option C's marginal cost over Option B is ~1 hour additional substrate work (new D1 provisioning + schema creation), not "substantially heavier" as initial framing suggested. Customer-facing workers cannot bind ops-db directly per MANDARIN, so both Options B and C require identity reads to route through internal-api service-binding — identical caller-side cost across the two options.

D1 database names are immutable (CF substrate constraint, confirmed). This ruled out shortcuts like "rename workspace-db to identity-db"; both Options B and C require real substrate work and honour the name-matches-shape principle. Option A (identity-in-workspace-db) was disfavoured because the database called rto-workspace-db carrying both workspace state AND identity state perpetuates a name-doesn't-match-shape shortcut — the kind of residue that compounds across migrations.

Decision. Identity tables live in a new dedicated database: rto-identity-db (with rto-identity-db-staging twin per Peel taxonomy).

Identity-as-first-class-subsystem. This decision establishes identity as its own architectural concern with its own database, its own service mediation boundary (via internal-api), and its own lifecycle. The framing composes forward with: InstaLearn credential issuance (the credential-issuance surface composes naturally with a dedicated identity subsystem); federation per spine §7 (external IDP integration has a clean integration boundary); future RTO SSO integration (same — clean federation surface).

Classification per MANDARIN DATA TAXONOMY. rto-identity-db is Peel category — per-env, app-writable, schema-locked, with the -staging twin per the Peel pattern. Identity data is operational state that twins between environments, not reference data (Pith) or sync-output (Sync-output) or public intake (Intake).

Service mediation via internal-api. internal-api gains a binding to rto-identity-db and hosts identity-read endpoints. No new worker is provisioned — internal-api already has dispatch-by-pathname routing, OPS_DB + WORKSPACE_DB bindings, and identity-adjacent surface (passkey.ts WebAuthn handling). Adding identity endpoints is a config + endpoint addition, not new infrastructure. This composes with EXTERNAL-WRAPPER-CONFORMANCE-01's framing of internal-api as canonical service mediation layer.

Customer-facing workers route via service-binding. Per MANDARIN, customer-facing workers (apps/site, apps/workspace, workers/prelaunch) cannot bind rto-identity-db directly any more than they can bind rto-ops-db. The 18 identity-read sites surfaced by substrate scan migrate from direct workspace-db queries to service-bound calls through INTERNAL_API (the service binding is already configured in apps/site and apps/workspace wrangler configs).

Consequences.

  • New D1 databases provisioned: rto-identity-db (prod) and rto-identity-db-staging. Schema per ADR-024 design lands at provisioning time.
  • internal-api gains IDENTITY_DB binding to rto-identity-db (plus staging counterpart). New identity-read endpoints added to internal-api's pathname dispatch surface (~6-10 endpoints per the analysis): user-by-email, user-by-id, tier lookup, credentials list, portal-invite lookup, member-list, etc.
  • Customer-facing identity reads route via existing INTERNAL_API service binding. Caller-side conversion: ~18 sites across apps/site + apps/workspace + workers/prelaunch (~54-90 LOC). Internal-api side: ~180-500 LOC.
  • Identity is now first-class architecture. Future briefs cite "the identity subsystem" rather than treating identity as an attribute of workspace or ops. InstaLearn, federation, RTO SSO, and future identity-touching work compose against this subsystem.
  • OPS-DB-SPLIT-SHAPE-DECISION-01 is now identity-out-of-scope. That brief, when it drips, decides the ops-db remaining-tables shape (orgs, partner_accounts, billing_*, etc.) only. Identity is removed from its scope. The three shapes (4-cluster / 2-DB / 3-DB-narrative) still apply to non-identity ops-db content; this decision constrains none of them.
  • IDENTITY-MODEL-MIGRATION-01 (Phase 3 of IDENTITY-MODEL-RATIONALISATION-01) executes the migration against rto-identity-db as target. Migration brief structure: Phase 1 schema creation + binding wiring; Phase 2 per-row migration with id_migration_map; Phase 3 code-path update (caller-side + internal-api endpoints); Phase 4 explicit retirement of superseded tables (per candidate MIGRATION-COMPLETION-DISCIPLINE).
  • The cross-DB duplicate sub-briefs (CROSS-DB-DUPLICATE-USERS-01, CROSS-DB-DUPLICATE-PASSKEY-CREDENTIALS-01, CROSS-DB-DUPLICATE-MAGIC-TOKENS-01) inherit rto-identity-db as consolidation target. The fourth duplicate (products) remains out of identity scope.
  • Reversibility: choosing C and later wanting B means dropping rto-identity-db and re-migrating to rto-ops-db (~3-4 days). Choosing C and staying with C means no additional cost. The forward asymmetry favours C as the lower-regret position.
  • The Peel taxonomy admits the -staging twin naturally; no new taxonomy category required. CANONICAL-PROJECT-FILES-CURRENCY discipline applies — when canonical docs change, snapshot refreshes.
  • CF Access posture. No new CF Access policy is required for rto-identity-db. Per ADR-019, CF Access enforcement is at the worker-domain layer (admin.rtopacks.com.au, etc.), not the D1 layer. Identity-bearing data is protected by virtue of customer-facing workers routing reads through internal-api service-binding rather than through any CF Access policy applied to the D1 itself. If a dedicated identity-management admin surface is later proposed, that surface would require new CF Access policy treatment at the worker level.

Methodology observations. This decision is the fourth application within a 36-hour window of BRIEF-DRAFT-SUBSTRATE-VERIFICATION (candidate discipline, promotion-ready) — the substrate scan corrected initial architect-intuition on Option C's cost from "substantially heavier" to "~1 hour marginal." Without the discipline, this decision would likely have landed on Option B against an incorrect cost framing. The Phase 4 retirement scope reference (per MIGRATION-COMPLETION-DISCIPLINE candidate, Tim-filed 2026-05-27) makes explicit retirement a deliverable of the downstream migration brief rather than a "maybe later" item.

See ops/decisions/IDENTITY-MODEL-PLACEMENT-ANALYSIS-01.md for the full option analysis; ADR-024 for the schema model this decision places; MANDARIN DATA TAXONOMY in standing-rules.md for the Peel category; ADR-019 for the credential-surface Mandarin enforcement that composes with this placement; spine § 7 for federation forward-positioning.


ADR-026 — Cross-database relational constraints are application-layer concerns

Status: Accepted Date: 2026-05-27

Context. The canonical user-identity schema designed in ADR-024 placed client_id columns on users, tier_grants, impersonation_tokens, and portal_invites with REFERENCES clients(id) foreign-key clauses. ADR-025 placed the identity schema in rto-identity-db while clients lives in rto-workspace-db (or wherever client-data substrate eventually consolidates). IDENTITY-MODEL-MIGRATION-01 Phase 2 surfaced an execution-time failure: the canonical schema applied successfully to rto-identity-db at Phase 1a/1b (CREATE TABLE accepts cross-DB FK syntax without resolving the reference), but the first INSERT against users failed with SQLITE_ERROR: no such table: main.clients. SQLite enforces FK references at INSERT time; D1 databases are isolated, so main.clients is not resolvable from inside rto-identity-db.

This is a substrate-reality finding the canonical-design pass missed: REFERENCES syntax to a cross-DB table parses but does not enforce. The shape of the canonical artefact disagreed with the shape of the substrate, and the disagreement only surfaced at execution. The MANDARIN DATA TAXONOMY (per ADR-018) ratifies cross-DB relationships as the architectural norm (customer-facing workers cannot bind operator/identity DBs directly; reads route through service mediation), so the substrate constraint is not occasional — it is the default condition for relational shape across the RTOpacks substrate.

PRAGMA foreign_keys = OFF does not persist across statements on D1 (each query is a fresh connection), so per-session FK toggling is not a workaround.

Decision. Cross-database relational constraints in RTOpacks are application-layer concerns. The canonical-schema convention is:

  1. In-DB foreign keys are first-class. REFERENCES clauses to tables within the same D1 database are used normally. SQLite enforces them at INSERT time and they earn their place as a substrate-level integrity guarantee.

  2. Cross-DB logical relationships are documented as schema comments. Where a column logically references a table in another D1, the canonical schema documents the relationship as a comment on the column rather than a REFERENCES clause. Convention:

-- client_id logically references clients(id) in client-data substrate;
-- D1 cross-DB FKs not enforceable, validity is application-layer responsibility
client_id          TEXT,
  1. Application-layer is responsible for cross-DB referential integrity. Validity checks (does client_id exist in clients?), cascade semantics (when a clients row deactivates, what happens to its users / tier_grants?), and orchestration (cross-DB joins, transactions, deletions) live in worker code, not in the substrate.

  2. Cross-DB joins, transactions, and cascading deletes also cannot span DBs. A worker that needs identity-and-client data either (a) reads both DBs and joins in-app, or (b) routes the read through a service binding that owns both DBs (internal-api is the canonical mediator for identity-data + client-data joins per ADR-025).

  3. MANDARIN-driven separation makes cross-DB relationships the norm. This is not an edge case to design around. Customer-facing workers, internal-api, and ops surfaces operate against different DBs by design (per ADR-018 MANDARIN taxonomy + ADR-019 credential-surface enforcement). Cross-DB-FK-as-comment is the standing convention across all canonical schemas going forward, not a one-off for identity tables.

Consequences.

  • ADR-024 schemas adjusted. The users, tier_grants, impersonation_tokens, and portal_invites tables in rto-identity-db lost their REFERENCES clients(id) clauses. The client_id column shape (TEXT, nullable for T3 grants, NOT NULL for T4/T4A/portal_invites/impersonation_tokens) is preserved. The logical relationship is documented per the convention above. ADR-024 Consequences amended with a forward-pointer to ADR-026.
  • In-DB FKs survive. tier_grants.user_id REFERENCES users(id), tier_grants.granted_by REFERENCES users(id), credentials.user_id REFERENCES users(id), impersonation_tokens.actor_id + target_user_id, portal_invites.invited_by, magic_link_allowlist.added_by all reference users within the same DB and remain enforced.
  • All future canonical schemas adopt this convention. When designing new tables that logically reference tables in other DBs (e.g. plan/entitlement/metering tables per ADR-021 referencing clients across DBs; future federation tables referencing external IDP records; any worker-DB referencing identity records), the cross-DB relationship is documented in comments. The REFERENCES keyword is reserved for in-DB integrity.
  • Application-layer FK enforcement becomes a brief-able pattern. Where cross-DB referential integrity matters (e.g. preventing a tier_grant for a non-existent client_id), the discipline is implemented in the issuing worker, not in the substrate. Future briefs may codify the validation pattern (e.g. internal-api endpoints that own multiple DB bindings can validate at write time).
  • Joins, transactions, and cascading deletes cannot span DBs. When a multi-DB read is required, the canonical path is a service binding to a worker that holds both bindings (internal-api is the canonical multi-DB mediator). When cascading deletes are required across DBs, the cascade is implemented as a series of writes through the mediating worker, not via DB-level ON DELETE CASCADE.
  • Schema-doc-comment convention earns its place as a canonical artefact convention. ADR-018's documentation-discipline becomes ADR-026-aware: canonical schemas don't omit cross-DB relationships, they document them as comments. This makes the multi-DB substrate's relational shape visible at schema-read time even when the substrate cannot enforce it.
  • The IMM-01 Phase 2 schema-fix-sequence (rebuild the four affected tables in rto-identity-db + rto-identity-db-staging without REFERENCES clauses) is a one-shot remediation for the pre-ADR-026 schema. The forward-canonical schema at scripts/identity-schema.sql adopts the new convention from the start; future identity-db re-creations apply the corrected schema natively.
  • Validate-load-bearing-assumptions standing rule reinforced. ADR-024's REFERENCES clauses were a design assumption that the canonical schema would behave like a single-DB schema. The assumption was not validated against the D1 substrate at design time. The finding became visible only at first INSERT, after Phase 1a/1b schema-creation had already succeeded (creating an asymmetric failure mode: schema looks correct, breaks at first use). The discipline cure: when a canonical schema references tables in other DBs, the validate-load-bearing-assumptions step is "test an INSERT, not just a CREATE TABLE."

Methodology observations. This ADR captures the twelfth in-session application of BRIEF-DRAFT-SUBSTRATE-VERIFICATION (formally codified at STANDING-RULES-PROMOTION-02 close earlier today) — substrate caught what canonical text didn't notice: that REFERENCES syntax parses but doesn't enforce across DBs. It is the first in-session canonical-text-vs-substrate-constraint mismatch caught at execution time rather than draft time. The methodology worked, but at higher cost than draft-time catch would have been; a candidate IMM-01 close-report observation is whether earlier verification (e.g. INSERT-one-test-row at Phase 1a) would have caught it. validate-load-bearing-assumptions (from the CLAUDE.md root rules) is confirmed as a load-bearing methodology pattern — earned its place during VERSION-UPGRADE-01 in May and continues to earn applications; promotion to memory-pinned candidate worth tracking.

See ADR-018 MANDARIN DATA TAXONOMY; ADR-019 credential-surface enforcement; ADR-024 (Consequences amended with forward-pointer to this ADR); ADR-025 identity-db placement that surfaced this constraint; scripts/identity-schema.sql for the corrected canonical schema; standing rule validate-load-bearing-assumptions in CLAUDE.md and BRIEF-DRAFT-SUBSTRATE-VERIFICATION in standing-rules.md.


ADR-027 — Environment parity is a canonical commitment

Status: Accepted Date: 2026-05-27

Context. RTOpacks operates a two-stage deployment workflow (dev → prod), not the three-stage convention (dev → staging → prod) that wrangler config templates default to. Until ENVIRONMENT-NAME-RENAME-01 (2026-05-27 PM), the wrangler env.staging block was the colloquial "dev" environment served at *.rtopacks.dev — an infrastructure-convention name inherited at config-template time that did not match operational reality. The mismatch produced semantic collision with the unrelated staging.rtopacks.com.au pre-launch artefact and required constant mental translation in briefs, deploy instructions, and substrate discussions.

Additionally, the MANDARIN principle has protected database isolation within an environment (Pith, Sync-output, Peel, Intake categories) but environment parity across environments has been honoured informally rather than canonically. The IMM-01 (IDENTITY-MODEL-MIGRATION-01) work surfaced this: Phase 1a/1b/2/3a/3b were executed against prod substrate without explicit environment-scope enumeration, leaving dev in pre-IMM-01 state by default. The asymmetry would have compounded across every subsequent brief until corrected.

The illuminated-fence framing (Tim-articulated 2026-05-27 PM) names the operational economics: when both halves of substrate look identical at every layer the operator controls, debugging, migration sequencing, and propagation questions all simplify — "where does the haystack live?" answers itself. Without environment parity, every operational question carries an implicit translation tax.

Pre-revenue is the right moment to pay the architectural cost. There are no clients to break, no team to coordinate, no revenue to interrupt. The cost of getting environment parity right now is hours; the cost of getting it right after first client onboarding is days plus customer impact.

Decision. Environment parity is canonically committed at the schema layer and at the operational-vocabulary layer. Data parity is not committed.

Operational vocabulary is dev/prod. RTOpacks has two environments: production (top-level wrangler config, *.rtopacks.com.au domains) and dev (env.dev block, *.rtopacks.dev domains). The wrangler env name, deploy scripts, source code env-checks, KV cache prefixes, living docs, and brief framings all use dev and production. Infrastructure-convention naming (staging) was operationally misaligned with the two-stage workflow and has been retired in source per ENVIRONMENT-NAME-RENAME-01.

Schema parity is canonically committed. Each environment holds structurally-identical substrate: same canonical schemas applied to both environments before downstream code work proceeds. The Peel taxonomy provides the intra-env twin pattern (prod has prod-staging; dev does not require a dev-staging — dev itself is the pre-prod test environment). Read-only reference data shared across environments (rto-nrt-db, rto-abs-db, rto-licensing-db) remains shared per MANDARIN; the parity principle applies to environment-twinned data only.

Data parity is NOT committed. Substrate-honestly different data per environment is expected. Per CANONICAL-IDENTITY-VIA-UI-ONLY (standing-rules.md, canonical-work cluster), dev substrate populates via UI paths against dev — not by mirroring production data. Dev's rto-identity-db-staging may be empty when prod's rto-identity-db holds rows; that is correct, not a regression. Committing to data parity would over-constrain the architecture and conflict with the UI-only entry principle.

Migration briefs explicitly enumerate environment scope. Brief framings going forward state "Environment scope: production + dev" or "Environment scope: production only" with justification. Implicit single-environment scope is a brief-framing failure caught at Gate 1 substrate-state spot-check.

Substrate-name lag is documented temporary state. D1 database names retain -staging suffix per ADR-018 immutability cost (D1 names cannot be renamed; create + migrate + retire is the only path). KV namespace SESSION_KV_STAGING and substrate-baked ID prefixes (ten_staging_, usr_staging_) inherit the same immutability class. Alignment of these substrate names to operational vocabulary is planned in D1-NAME-ENVIRONMENT-RENAME-01 (queued forward brief, sequencing TBD post-IMM-01 close, possibly bundled with other D1-name historical-artefact cleanup such as the engine-db-oc display-name fix). Until that brief lands, the asymmetry is canonically documented — operational vocabulary aligns with operator mental model; D1 substrate names lag with explicit forward-pointer.

The staging.rtopacks.com.au URL is unrelated. A separate concept entirely: the pre-launch apex artefact (production substrate behind a prelaunch landing). It retires on its own timeline when the prelaunch landing comes down and staging.rtopacks.com.au becomes www.rtopacks.com.au or just rtopacks.com.au. ADR-027 does not touch this URL or its eventual retirement; the env-name collision was self-limiting in time and has been resolved upstream by renaming env.staging to env.dev.

Consequences.

  • All four customer-facing CF workers and internal-api retired their -staging names and stood up under -dev names during the IMM-01 close arc. New worker names: rtopacks-internal-api-dev, rtopacks-admin-dev, rtopacks-workspace-dev, rtopacks-site-dev. Service-binding cross-references updated in lockstep.
  • Source code env-checks (env.ENV === "dev") and KV cache prefixes (dev:) replaced their staging predecessors. lib/env-urls.ts comments rewritten to remove the now-obsolete "inversion" framing.
  • Living docs updated to dev/prod vocabulary (workers/inventory.md, ops/standing-rules.md, architecture/data-architecture.md, architecture/database.md, ops/infrastructure-reference.md). Historical artefacts (time-machine, archive, closed briefs) left untouched per artefact-as-historical-record principle.
  • Migration briefs going forward must enumerate environment scope as part of brief framing. The IMM-01 brief did not, and the resulting implicit-prod-only scope produced the substrate asymmetry this ADR addresses. Future briefs catch this at Gate 1.
  • Substrate-state asymmetry across environments is a Gate 1 finding to surface immediately and resolve before downstream work proceeds — not deferred to a "we'll get to it later" item.
  • Deployed-code asymmetry across environments is a known failure mode (today's discovery): worker rename creates a new worker; route reassignment requires explicit deletion of the previous holder before the route attaches to the new worker. See ROUTE-MIGRATION-REQUIRES-OLD-WORKER-DELETION in standing-rules.md for the discipline this surfaced.
  • ADR-018's per-resource-type rename feasibility analysis is honoured rather than overridden — workers rename freely (new worker created, old retired); D1s rename only via create-migrate-retire and live in their own dedicated brief. The composition is intact.
  • Reversibility: this decision can be reversed by reintroducing three-stage naming, but doing so would require justifying the convention against the operational reality of two-stage workflow. The operational shape is the load-bearing constraint; the naming serves the shape.

Methodology observations. This ADR is the first canonical articulation explicitly born of operational asymmetry surfaced during execution rather than designed-in-advance. The pattern that produced it: Tim's question "what is the cost to actually call dev dev and prod prod" caught Claude drifting toward canonical-work bundling (treating an operational task as architectural smokescreen), and Tim's follow-up "this is foundational" caught Claude over-reverting (abandoning the architectural work entirely). The middle path — foundational work done deliberately, not bureaucratically — is what produced ADR-027. The principle worth pinning forward: when a methodology response feels disproportionate to the operational task, the methodology may be the drift, not the protection. METHODOLOGY-SERVES-FOUNDATION (codified in standing-rules.md) is the canonical articulation.

SUBSTRATE-NAME-FOLLOWS-OPERATIONAL-SHAPE reached promotion threshold during this work — when standard naming conventions don't match operational workflow shape, substrate-name follows operational-shape, not convention. ROUTE-MIGRATION-REQUIRES-OLD-WORKER-DELETION earned four observable applications during the IMM-01 close arc with a refinement (pattern-routes require delete-first; custom-domain routes offer in-place reassignment via deploy prompt).

See ops/standing-rules.md for MANDARIN DATA TAXONOMY; ADR-018 for per-resource-type rename feasibility; ADR-025 for the Peel taxonomy that admits the -staging twin pattern within an environment; client-spine.md dispositional layer for the operator-vocabulary primacy that underpins SUBSTRATE-NAME-FOLLOWS-OPERATIONAL-SHAPE; ENVIRONMENT-NAME-RENAME-01 brief and close report for the mechanical work that landed the operational vocabulary; IMM-01 (IDENTITY-MODEL-MIGRATION-01) for the migration whose dev-side gap surfaced the asymmetry this ADR addresses.


ADR-028 — System observability is a canonical substrate

Status: Accepted Date: 2026-05-28

Context. RTOpacks is not transactional-CRUD software. It is a multi-layer machine: client-tenant activity across customer-facing workers, sitting above a substrate of continuous internal work — ingestions, reconciliations, pre-calculations, matching, inbound webhooks, outbound API calls, scheduled sync runs. Many independent flows run concurrently. In a system of this shape, failures characteristically live in the interactions between components — a sync job starving a lookup, an external-API timeout cascading into a user-facing delay — not in any single component examined alone.

ADR-017 (the in-and-out bus pattern) already named "Observability fails" as a failure mode of scattered flows and committed to instrumentation "applied at the bus level, not per-consumer." ADR-017 established that observability should be uniform. It did not ground where observability data lives, how it is queried, or the commitment that every worker feeds it. This ADR is that grounding — the storage-and-viewing layer for the observability ADR-017 demanded.

The question surfaced concretely: the IMM-01 Phase 3d Gate 1 audit found apps/site writing 8 telemetry/log records directly into rto-ops-db. Inspection showed these were not customer data (the contamination the OPS SURFACE RULE was built against) but the worker logging its own activity — observability data with no home in the MANDARIN taxonomy. The four categories (Pith, Sync-output, Peel, Intake) describe application and reference data. None describes the machine's observability of itself. Per the taxonomy rule (standing-rules.md §201), data fitting none of the four requires either reclassification or a new category with Tim sign-off. System observability is a new category; this ADR names it.

The motivating failure case. A real account (Technology One, related via Kieran) of a system scaled without observability: repeated failures under load, with clients escalating; the problem vanished at ~4am as load wound down and returned as load returned — a load-dependent failure, invisible at rest, emergent only under concurrency. The team knew it was load-related but could not localise it: symptom without instrument. Lacking any record of the real conditions of failure, they could not reproduce it in production and were forced to build a separate environment and synthesise load to chase it — a prolonged, expensive effort. The three requirements this dictates:

  • Instrument before load arrives, not after. Their nightmare was retrofitting gauges onto a system already failing. RTOpacks is pre-launch — it holds the advantage they lacked. The instrument is built before the load, so the first load-dependent problem already carries a trace.
  • Coverage must be uniform across the whole machine. Load problems hide in the gaps between instrumented parts; partial coverage reproduces the blindness on whatever is left dark.
  • The trace must persist and be time-queryable. "It went away at 4am" means the evidence is temporal — the capability to ask "what did the system look like in the 90 seconds before it slowed last Tuesday" requires retained, time-queryable telemetry, not a live-only dashboard that forgets.

This is the concrete form of the standing failure mode RTOpacks designs against — "phones ringing while I'm debugging." Real-time gauges are the defence: find it on the wall, not from a phone call.

Decision. RTOpacks commits to a system-wide observability substrate as a canonical architectural layer. Observability data is two distinct things with two distinct homes:

  • Live telemetry → Cloudflare Analytics Engine. High-volume, fire-and-forget, real-time "what is happening now across the whole machine": page/traffic/lookup/request rates, latency, error rates, external-API timing, threat signals. Written continuously from every worker; queryable live and over retained history; engineered so writes do not backpressure the work that produces them. Analytics Engine is the Cloudflare-native instrument purpose-built for this workload — the platform equivalent of the external telemetry products larger systems adopt.

  • Audit / activity records → D1. Lower-volume, durable, correctness-bearing records of "who did what, when," with latent analytical value (post-hoc pattern analysis — code hotspots, user pathways — is deliberate after-the-fact querying that D1 serves). Distinct question from the live gauges: what happened and what patterns emerge, versus what is happening now.

The two are complementary, not redundant. The machine wants both.

Scope is the whole machine. Customer-facing workers and substrate workers alike (sync, reconcile, internal-api, ingest, customer-facing) instrument into the same layer. Uniform coverage is the anti-blindness requirement — it is structural, not optional per-worker.

Why Analytics Engine over alternatives. A dedicated telemetry D1 (rto-telemetry-db, Peel) would achieve separation from ops-db but reintroduces the scale-degradation the motivating case warns against — D1 is a transactional store, and diagnostic-at-scale queries over millions of rows are exactly where it degrades. Routing telemetry synchronously through internal-api (the literal ADR-017 bus) adds a blocking service-call hop to high-volume fire-and-forget writes; a page-view beacon must never make the page wait on an internal round-trip — right principle, wrong shape for this flow. Analytics Engine honours the bus principle (uniform instrumentation) without the synchronous-hop cost, and fits the workload by design. The cost accepted: a genuinely new infrastructure type (distinct write and query model from D1) and a learning curve; and a viewing layer ("see it on the wall") that is a further build beyond ingest, recorded as forward work.

Consequences.

  • A new MANDARIN category is named: Telemetry (system observability). Live telemetry lands in Analytics Engine (not a D1 — the category's substrate is intentionally outside the D1 taxonomy). Audit/activity records remain D1 (Peel). The taxonomy update is recorded in standing-rules.md in the same commit.
  • Every worker instruments into the observability layer. New worker work instruments from the start; existing workers are brought under the layer as their owners are touched, per ADR-017's migration posture — not a single mass refactor.
  • Telemetry writes are fire-and-forget as a hard constraint: the page renders and the job runs whether or not telemetry lands. A telemetry path that can hang a request has reintroduced the failure it was meant to detect.
  • Uniform pipes, lean gauges: the instrumentation path is built into every worker (uniform coverage, non-negotiable), but the metric set starts small and grows as real questions arise. Coverage is structural; metric depth grows with need. This guards against the opposite of the motivating failure — a telemetry firehose nobody reads.
  • The "see it on the wall" real-time view is a further build (a query/visualiser layer over Analytics Engine), recorded as forward work, not delivered by this ADR.
  • First application (factually reconciled 2026-05-29 — the ADR-028 decisions stand; only the first-application identification was wrong): the apps/site ops-db write retirement was originally framed as the first writer into this ADR's substrate (5 telemetry to Analytics Engine, 1 audit to D1). Substrate audit dissolved that scope — nothing migrated to this ADR's substrate; the retirement landed as two narrower retire briefs (gone-is-gone) and the remaining writes stayed on ops-db. Analytics Engine and rtopacks-audit-* are declared by this ADR (declared-by-decision); the audit DB was provisioned 2026-06-15 (see ADR-029), Analytics Engine not yet; the first genuine writer is still pending. Per-write inventory and disposition at docs/docs/ops/audits/IMM-01-Phase-3d-Gate-1-audit.md §6.1; close reports at briefs/closed/WAITLIST-LOOKUP-LOG-RETIRE-01-close.md and briefs/closed/APIREQUESTS-LOG-RETIRE-01-close.md.
  • Reversibility: the audit-vs-telemetry split and the Analytics-Engine choice can be revisited, but the system-wide-uniform-observability commitment is foundational — partial coverage is the failure mode, so reversing toward per-worker discretion reintroduces the blindness this ADR exists to prevent.

Methodology observation. This ADR, like ADR-027, was born of a small operational finding (8 stray writes) that surfaced a foundational gap. The discipline that produced it: the writes were not waved through as a rule-violation to remediate, nor inflated into an unscoped observability project — the prior question ("is this the contamination the rule targets, or an unnamed category?") was asked first, and the answer reshaped the work from "redirect 8 writes" to "name the observability substrate." Small findings surfacing big architecture is the working pattern; the guard is keeping the execute scope small (apps/site's 8 writes) while letting the decision be foundational (the ADR).

See ops/standing-rules.md for the MANDARIN DATA TAXONOMY (Telemetry category added by this ADR); ADR-017 for the bus pattern this ADR grounds; ADR-018 for cartography (the observability layer is a candidate for visual mapping); OBSERVABILITY-SUBSTRATE-DECISION-01 for the full decision analysis and the motivating case; the standing failure mode "phones ringing while I'm debugging" in project orientation.


ADR-029 — The audit substrate is a uniform activity stream

Status: Accepted Date: 2026-05-28

Context. ADR-028 named the audit/activity half of the observability substrate as D1 (Peel) and committed to whole-machine coverage: every worker that performs a durable, correctness-bearing action ("who did what, when") writes an audit record. (Factually reconciled 2026-05-29 — only the first-writer identification was wrong: this ADR was drafted assuming apps/site's mode_switch_log would be the first writer migrating into a new rtopacks-audit-*. Substrate audit dissolved that scope; mode_switch_log is not migrating, and the first genuine writer is still pending. The table-shape decision below stands unchanged.)

Provisioning that database forces a decision that cannot be deferred: what shape do audit records take across the whole machine? Two shapes are possible, and the first table created sets the precedent by example whether or not the precedent is chosen deliberately:

  • Shape A — one table per event kind. mode_switch_log, sync_run_log, setting_change_log, etc. Each action gets a bespoke table with columns fitted to that action. Per-table tidiness; an ever-growing table list; the cross-machine question ("what did the whole system do in this time window") requires stitching many tables together.
  • Shape B — one uniform activity table. A single activity table; every worker writes a row tagged with its source. The cross-machine question is one query filtered by time; event-specifics live in a flexible field rather than bespoke columns.

The decision governs every future audit writer, not just the first — so per CANONICAL-DECISION DISCIPLINE it is an ADR, settled before the execute brief builds against it, not an implicit consequence of the first table's schema.

The deciding argument. The audit substrate exists to answer the cross-machine question — this is ADR-028's entire motivating case (the load-dependent failure localised only by seeing across the machine at a moment in time: "what did the system look like in the 90 seconds before it slowed last Tuesday"). Shape B answers that in one query. Shape A makes it an assembly job — and the assembly cost comes due at the worst possible moment, when the system is failing under load and reconnaissance is most urgent and slack is least available. A design that taxes you hardest exactly when you need it most is the wrong design. Shape B also is the "illuminated fence down the middle of the mandarin" applied to observability: one place to look, filter by source — already knowing which paddock the haystack is in. Shape A is a dozen paddocks.

The cost of Shape B, stated honestly: event-specific fields (e.g. which mode a mode-switch targeted) live inside a flexible detail field rather than as typed columns, so deep analysis of a single event-type is clumsier than under Shape A's bespoke columns. This trade is correct for an audit log, whose constant query is the cross-cutting who/what/when/where (all typed columns under Shape B) and whose rare query is the single-event-type deep-dive. The common query stays trivial; the rare query gets slightly harder. For the high-volume metrics side this trade would weigh differently — but metrics are Analytics Engine's job per ADR-028, and Analytics Engine is purpose-built for deep aggregation. Each half of the observability substrate gets the tool that fits it.

Decision. The audit substrate is a single uniform activity stream: one activity table in rtopacks-audit-*, into which every audit writer across the machine inserts a source-tagged row. Audit records are not partitioned into per-event-kind tables.

The canonical activity table shape:

Column Type Role
id INTEGER PK (autoincrement) row identity
timestamp TEXT (ISO 8601) / INTEGER (epoch) when — the primary sort/filter axis
source TEXT which worker/surface emitted the record (e.g. apps/site)
client_id TEXT, nullable which tenant the action belongs to; NULL for operator/system actions
actor TEXT who performed the action — canonical user identity per ADR-024 (bare RFC 4122 UUID) for users, system:<worker-name> for system actors
action TEXT what happened (e.g. mode_switch)
detail TEXT (JSON) event-specific payload; the flexible field for everything not promoted to a column

The columns before detail are the constant-filter dimensions — directly queryable, strongly typed. detail is the single flexible field for event-specifics; querying inside it is the accepted clumsy path, reserved for the rare single-event deep-dive.

client_id is nullable by design and maps to the access-control model (ADR-020): operator-tier actions (T3, NULL client_id) and client-scoped actions (T4/T4A, populated client_id) both fit the same table, and the audit log can answer both "everything for client X" and "everything operators did" without reaching into detail. The nullability encodes the operator-vs-client distinction natively.

actor format is pinned to the canonical identity model, not left free-form. For user actors it holds the canonical user identity (the bare RFC 4122 UUID per ADR-024); for system/worker actors it holds system:<worker-name> (e.g. system:sync, system:reconcile). This is not premature convention-setting under lean-start: it adopts an existing canonical format (ADR-024) rather than inventing one, so the audit log does not silently drift from the identity model the rest of the substrate already commits to. Free-form actor would invite the first writer to coin a format the second writer copies — the same first-instance-sets-the-precedent trap this ADR addresses for table shape, applied to column contents. The operator-vs-client distinction is carried by client_id (NULL for operator/system), not by actor format — the two columns do not overlap in what they encode: actor is who, client_id is whose tenant.

Consequences.

  • rtopacks-audit-* will be provisioned with a single activity table at this shape (and rtopacks-audit-dev per env parity, ADR-027) when its first genuine audit writer lands. (Factually reconciled 2026-05-29 — only the first-writer identification was wrong: the original draft identified apps/site's mode-switch as the first writer, but substrate audit dissolved that assumption and nothing migrated to this substrate. The DB remained declared-by-decision but not yet provisioned; the first genuine writer was still pending.) When the first writer lands, it inserts as source = '<worker>', action = '<event>', with event-specifics in detail, per the shape above.
  • (Provisioned 2026-06-15 as rtopacks-audit-prod (5505ec39-8545-4495-90d4-39d68620fc10) and rtopacks-audit-dev (8c019cf6-79ff-4755-b9e7-c586237d2997) per the Cloudflare Naming Canon, which supersedes the rto-audit-db shorthand used in this and earlier ADRs (028/039) and ADR-045. Single activity table at the canonical shape, both envs character-identical, timestamp as TEXT ISO-8601. First writer: the Brick 3c onboarding correspondence log via the ADR-045 emission helper.)
  • (Reconciled 2026-06-15 — actor format: the original text cited usr_… as ADR-024's user-identity format. ADR-024 in fact canonises a bare RFC 4122 UUID; usr_… was never established for users. Corrected above to match ADR-024 and the verified substrate. The emission helper's actor validation accepts the bare UUID and rejects free-form — the correct permanent state, not an interim.)
  • Every future audit writer (whole-machine rollout per ADR-028) inserts into the same activity table with its own source. No new audit tables are created per worker. A worker that needs a constant-filter dimension not covered by the existing columns proposes promoting that dimension to a real column via a follow-on ADR — the column set can grow deliberately; it does not fragment into tables.
  • The cross-machine reconnaissance query is one SELECT ... WHERE timestamp BETWEEN ... [AND source = ... | AND client_id = ...]. This is the capability ADR-028 exists to provide.
  • The detail JSON is the soft spot by design. Anything discovered to be a constant-filter need is promoted to a typed column by ADR, not left in detail indefinitely. Lean-start applies: the column set begins minimal and grows with demonstrated need, mirroring ADR-028's "uniform pipes, lean gauges."
  • This is the audit (D1) half only. Live telemetry remains Analytics Engine per ADR-028 and is unaffected by this decision.
  • Reversibility: the activity-stream commitment is foundational for the same reason ADR-028's uniform-coverage commitment is — fragmenting back into per-event tables reintroduces the stitching cost the decision exists to avoid. The column set is extensible; the one-uniform-table shape is the load-bearing commitment.

Methodology observation. This ADR was surfaced by a brief-review question — "does the first table in a whole-machine DB set an accidental precedent?" — and the honest answer was that creating the database is the convention decision; deferring it to "the second writer" was a dodge, because the first table's shape is the precedent. Naming the decision explicitly and settling it before provisioning is the correction. Small provisioning act, foundational decision underneath it — the same pattern as ADR-028 (small finding, foundational gap), caught one layer earlier.

See ADR-028 for the observability substrate this grounds (audit/D1 half); ADR-020 for the access-control tier model client_id nullability maps to; ADR-027 for the env-parity commitment (-dev twin).


End of architecture-decisions log. New decisions appended below this line.


ADR-030 — Internal-api trust is closed by topological isolation, not a credential

Status: Accepted Date: 2026-05-30 Supersedes: the per-source-secret approach in INTERNAL-API-SOURCE-HEADER-AUTH-01 (a brief, now retired — not executed).

Context. workers/internal-api/src/index.ts mints a synthetic caller identity from the bare string value of the X-RTP-Internal-Source header (admin-worker → is_super:true, plus site-worker, workspace-worker), with no token, secret, or signature validated. The header is trusted purely because of where the request can arrive from — a privileged identity asserted by a forgeable credential. Pre-revenue, no customer data is at risk, but it is a real super-operator trust flaw.

Interim hardening is already closed and verified: workers.dev/preview URLs disabled on internal-api; the eight public-edge HTTP fallbacks in admin and site neutered (fail-closed, binding-first retained); and a WAF presence-block live on both zones (has_key(http.request.headers, "x-rtp-internal-source") on the internal-api hostnames → Block), forging the header now returns 403 at the edge before the worker runs. The external forgery door is shut; the internal trust model is not. internal-api still trusts the header from callers arriving over its service binding.

Two terminal fixes were considered:

  • A credential — validate the header as a per-source shared secret (INTERNAL-API-SOURCE-HEADER-AUTH-01). Locks the door.
  • Topological isolation — make internal-api binding-only, with no public route at all, so the header is trustworthy by construction. Removes the door.

internal-api has a public route for one reason: webhooks and callbacks land on it (Stripe at /billing/webhook; QuickBooks OAuth at /billing/qb-callback). Removing the public route requires those to land somewhere else first.

The deciding argument. Removing the door beats locking it. A secret is a standing credential: it must be stored, rotated, and leak-guarded, and it adds an auth branch in the worker that can be subtly wrong. Isolation is subtraction — once internal-api has no public route, there is nothing to forge from outside and no credential to maintain. It is also Cloudflare's documented pattern (a Worker not reachable via the public Internet, only via service binding), and it composes with the binding-as-trusted-channel principle: a service binding dispatches inside the Cloudflare runtime and cannot be forged or intercepted from outside the account, so identity carried over it does not need a secret to be trustworthy. A secret built now would be expensive throwaway work, because the terminal answer (isolation) is already known — "why touch it twice."

Honest calibration. The external door is already shut. The only residual during the isolation runway is the inside-job vector — a compromised in-account worker forging the header over a binding. For a solo operator running only his own code, pre-revenue, that risk is remote today. A docs-verified fact bounds what isolation alone achieves: a service-binding call does not give the callee a verified identity of the calling worker — internal-api knows only that a request arrived over a binding, not which worker sent it. So isolation removes the external attack surface but does not, by itself, bind privilege against an inside-account actor. The condition that escalates the inside-job risk is other people's hands or untrusted third-party code running inside these workers — the trigger is people and dependencies, not elapsed time. (This is precisely why the fork below is resolved toward closing the inside-job door now, while it is cheapest: the cost only rises once customers, integrations, and a larger fleet arrive.)

Decision. internal-api's trust flaw is closed by topological isolation: internal-api becomes binding-only with no public route. The per-source-secret approach is rejected as the terminal fix and its brief retired. The work is sequenced as a three-part programme (INTERNAL-API-ISOLATION-PROGRAMME-01):

  1. Migrate the public-hostname (Pattern-B) callers onto the service binding.
  2. Extract the public receiving surface (/billing/*) into a dedicated public worker that performs cross-boundary authentication — HMAC signature verification for the Stripe webhook, OAuth state/code validation for the QuickBooks callback — then calls internal-api over the binding.
  3. Remove internal-api's public route → binding-only.

Resolved sub-decision (2026-05-30, by ADR-031). Whether, at route removal, the X-RTP-Internal-Source header is retired in favour of non-forgeable per-caller identity (e.g. distinct WorkerEntrypoints per caller class — closing the inside-job vector), or kept as an internal trust signal (simpler — inside-job vector stays open), was originally left open in this ADR. ADR-031 and the CHANNEL SEPARATION RULE now resolve it: non-forgeable per-caller internal identity is required, and the bare-header trust model is retired. Pure isolation that keeps trusting the header internally ("pure-B") is no longer an option. What remains for the route-removal sub-brief's Gate 1 is validating the mechanism against internal-api's actual structure — single fetch handler with internal routing, or already modular — and recording it in a follow-on implementation ADR. The decision (close the inside-job door) is settled; only the mechanism is open. This ADR commits to isolation; ADR-031 commits to the non-forgeable internal identity.

Consequences.

  • internal-api loses its public route on programme completion. A new public webhook-receiver worker is provisioned (NAMING-PAUSE RULE applies to its hostname/name; Stripe and QuickBooks dashboards repointed to it — Tim's hands).
  • The WAF edge-block and neutered fallbacks remain the primary control, unweakened, until the public route is gone. After that the WAF rule is retained as inert defence-in-depth (it matches nothing once there is no public route). Any relaxation of a security control is a separate, explicit Tim decision.
  • Stripe and QuickBooks each require a current EXT-API reference doc in docs/ops/ before the receiver ships (EXT-API RULE).
  • Reversibility: re-adding a public route would reintroduce the exact exposure this ADR exists to remove — so the binding-only end state is meant to be sticky, not casually reversible. That stickiness is the point.

See ADR-020 (access-control tier model the synthetic identity maps to); ADR-027 (environment parity — the binding is wired in dev too, so dev and prod use the same channel and no public fallback is ever needed); ADR-031 (the channel-separated architecture commitment that resolves the inside-job fork); and the CHANNEL SEPARATION RULE (the durable channel/credential principle this programme is the first build of).


ADR-031 — Channel-separated service architecture is a canonical commitment

Status: Accepted Date: 2026-05-30 Depends on: ADR-030 landing first (same arc); ADR-030 is the first application of this commitment.

Context. The forgeable X-RTP-Internal-Source exposure (May 2026) was not an isolated bug — it was an instance of a class of flaw: privileged or internal identity asserted by a forgeable credential reachable over a public surface. ADR-030 closes that instance for internal-api by topological isolation. But the Gate-1 audit of even that single fix found the public-surface dependency was broader than known — server-side callers, browser callers fetching an internal hostname directly, and external webhooks — which is evidence the pattern is pervasive across the fleet, not confined to one worker. Left to per-incident fixes, the same class recurs wherever a new service-to-service path or a new browser→service path is added without the discipline. Each recurrence is found late (at worst, by an attacker) and patched expensively.

The decision is to stop treating this as a sequence of incidents and make the mitigation architectural and forward-binding: channel separation is the default design for all service interaction, so the flaw class is mitigated by construction on every new path, not discovered and patched after the fact. This governs implementation across every module and substrate, so per CANONICAL-DECISION DISCIPLINE it is an ADR, not a brief-scoped choice.

Decision. Channel-separated service architecture is the canonical default for all RTOpacks development going forward. The trust boundary is RTOpacks' own Cloudflare account.

  • Within the boundary (service-to-service, same account): use the service binding (env.X.fetch). The binding is the trusted channel — it dispatches inside the Cloudflare runtime and cannot be forged or intercepted from outside.
  • Across the boundary (separate account — e.g. the UCCA Inc engine once it splits out; external vendors, customer LMSs, third-party and government APIs): authenticate cryptographically (signed token or mutual auth, verified server-side). Never a forgeable claim on a public surface. The boundary triggers the rule in both directions — outbound and inbound.
  • Browser → internal services: the browser calls a same-origin route which server-side uses the binding. Browsers never call internal hostnames directly. (A browser cannot use a service binding; a direct browser→internal-hostname call is a public-surface dependency by definition.)
  • Fail closed: an internal call whose binding is unavailable errors loudly; it never falls back to the public edge. The "binding-first with public-HTTP fallback" pattern is forbidden.
  • The account boundary is the outer trust unit; within it, internal caller identity must be non-forgeable. A binding establishes that a request arrived over a trusted channel, but not which in-account worker sent it — so a bare header any in-account worker can set is not sufficient for privileged internal identity. Eliminating the in-account inside-job vector — binding identity to the private entrypoint a caller arrives through (distinct WorkerEntrypoints per caller class), or another non-forgeable per-caller mechanism — is required, not optional. A forgeable internal claim (the bare X-RTP-Internal-Source header trusted on its face) is non-compliant. (This resolves the internal-api pure-B vs bind-by-entrypoint fork — opened in ADR-030 — toward non-forgeable identity; the mechanism is validated against internal-api's actual structure at the route-removal Gate 1, but pure-B is rejected. The invariant is mandated now; only the mechanism is chosen later.)

What's not in scope (unchanged from the companion rule): end-user-facing product surfaces, which are public by design — this governs service-to-service calls, not user endpoints; and the rare, documented case of deliberately routing internal traffic over the edge for an edge-only feature.

Consequences.

  • New service paths are designed channel-separated from the start — the ground-up posture. New external integrations carry cryptographic auth and an EXT-API reference doc (EXT-API RULE) as a precondition of shipping.
  • Existing violations are dispositioned by a fleet channel-sweep (the companion rule's audit lens). The internal-api isolation programme is the first sweep; it has already surfaced the browser-direct-to-internal pattern as a general shape (workspace admin pages), which the sweep now checks for elsewhere.
  • Cost, stated honestly: every browser→internal path gains a same-origin route layer; every internal call gives up the public-HTTP fallback; and — under the non-forgeable-internal-identity requirement above — every existing internal caller migrates from the bare header to the per-caller mechanism. That includes the Pattern-B callers already moved onto the binding (INTERNAL-API-PATTERN-B-TO-BINDING-01), which currently still carry the header and are therefore A-pending. The size of that migration is established by audit against internal-api's actual structure before it is committed — it is not assumed small. This cost is paid at design time, deliberately, instead of as incident response later. That trade is the entire point — the alternative is paying it under exploitation, with customer data at risk.
  • A security dividend falls out of the browser pattern: once browser callers go via same-origin routes, the session cookie can become httpOnly (no longer read by client JS), closing the JS-readable-credential gap.
  • This is a default, not a cage — the not-in-scope carve-outs remain, and deviations are allowed when documented with a reason. The non-forgeable-internal-identity requirement, however, is a mandate within the boundary, not a default to be deviated from casually.
  • Reversibility: as a forward-binding default it is meant to be sticky; abandoning it would reintroduce the recurring flaw class it exists to retire.

Companion discipline. The CHANNEL SEPARATION RULE operationalises this commitment: every brief-draft and every audit checks channel compliance, and the fleet channel-sweep enumerates and dispositions existing violations. The rule is how this ADR is enforced on every piece of work.

See ADR-030 (first application — internal-api isolation; this ADR resolves the inside-job fork ADR-030 opened); the CHANNEL SEPARATION RULE (companion enforcement discipline); the HARD SEPARATION RULE (sibling — separates data domains, where this separates communication channels); and ADR-027 (environment parity / illuminated-fence — bindings are wired in dev too, so dev and prod use the same channel and no public fallback is ever needed).

ADR-032 — Client-file placement in a dedicated rto-client-db

Status: Accepted Date: 2026-06-06 Drafted from: confirmed CLIENT-FILE-SCHEMA-01 build state (Gate 5 close); DBs verified live against the account.

Context. The client file (client-spine.md §5) is the apex Layer-3 entity — billing, identity, credentialing, and operational state all hang off it. CLIENT-FILE-SUBSTRATE-AUDIT-01 established the substrate reality: the RTO-org source data is rich and present in the Pith (rto-nrt-db, the enriched clean rto_* family); no dedicated client database existed; and the only proto-record was a thin orgs table living in ops-db (not rto-workspace-db — correcting an inherited resource-inventory premise). Because the client file is surfaced to the customer (their own auto-populated file), it cannot live in ops-db (OPS-SURFACE-RULE). The placement question was therefore: co-locate in rto-workspace-db (alongside People/Studio/Courses) or stand up a new dedicated database.

Decision. The client file is placed in a new dedicated database, twinned across environments:

DB role UUID
rto-client-db prod a8c23529-e29f-47a6-9ee5-0623cae9672a
rto-client-db-staging dev twin b41949c0-13c4-494e-bf45-a3226059a20a

Naming/env/twin pattern matches the rto-identity-db precedent (ADR-025) exactly. Rationale: it is the apex entity everything anchors to; a clean dedicated home keeps its attachments legible (client-spine.md §5, deliberate-attachment principle); it follows the ADR-025 identity-db placement pattern (low marginal cost, clean separation); cross-DB references are already the MANDARIN norm (ADR-026/031), so co-location offered no decisive saving; and pre-revenue is the right time to pay the architectural cost once (build-it-right-once).

The orgs proto-record is not migrated wholesale. Its billing/seat fields (seat_limit, admin_only_limit, billing_tier, billing_status, billing_email, billing_parent_id, billing_group_id, …) remain in ops-db — they are legitimately operational and never customer-surfaced, so the OPS-SURFACE-RULE is preserved. Only the customer-facing org identity and the TGA-mirror become the client file.

Canonical client id. client_files.client_id is the single canonical client id, shared across three databases — identity-db (tier_grants.client_id), ops-db (orgs.id, billing/seat retained there), and the new client-db. Verified against the substrate: orgs.id == identity client_id == client_files.client_id (the slug). The three relate cross-DB by id, at the application layer (ADR-026 — no enforced cross-DB FK). Billing lookup is ops-db orgs WHERE id = client_files.client_id. The redundant billing_org_id bridge column was dropped at schema review.

MANDARIN classification. rto-client-db is Peel (per-env, app-writable, schema-locked, env-twinned) — same category as identity-db. The mirror source (rto-nrt-db rto_* family) is Pith (shared, read-only, single instance). The copy-out reads Pith → client-db (Layer 3) per the sanctioned client-spine.md §5 pattern: the mirror is never mutated; drift-detection works by the client file referencing the mirror, not by writing the Pith. Lineage is captured per mirrored row (provenance / source / mirrored_at).

Schema shape (12 tables). Apex client_files; core rtos (mirror of the enriched clean family); rto_restrictions (the one justified raw-family exception — restriction detail is absent from the clean family but is material regulatory state for a compliance client file); and 9 mirrored clean-family children (rto_addresses, rto_contacts, rto_legal_names, rto_trading_names, rto_registrations, rto_scope_v2, rto_scope_changes, rto_classifications, rto_web_addresses). The vessel is Type-1-shaped (rto_code NOT NULL; client_files deliberately not FK'd to rtos, leaving room for clients with no regulator mirror). Type-2/Type-3 clients (self-reported / operational-state-only per client-spine.md) will require a later schema evolution (nullable rto_code).

Consequences. - A new Peel database joins the substrate, twinned dev/prod, schema-parity-verified byte-identical at creation (CLIENT-FILE-SCHEMA-01 close). - Not yet bound to any worker — binding deferred to the first consumer (CLIENT-FILE-MINT-01 / the admin-surface brief) per least-surface discipline. - The scope-representation divergence (client-db rto_scope_v2 vs workspace-db rto_scope_entries/rto_scope_unit_choices) is cued, not resolved (CLIENT-FILE-SCOPE-RECONCILIATION-01). - The resource-inventory canon needs a reconciliation pass — it implied the client-file home was rto-workspace-db; the substrate showed orgs in ops-db. Truth-before-paperwork.

See also: ADR-025 (identity-db placement precedent + Peel taxonomy); ADR-026 (cross-DB FK-as-comment / application-layer referential integrity); client-spine.md §4 (three-layer architecture, copy-into-client-file model) and §5 (the client file as apex entity); OPS-SURFACE-RULE and MANDARIN DATA TAXONOMY in standing-rules.md; CLIENT-FILE-SUBSTRATE-AUDIT-01 findings; CLIENT-FILE-SCHEMA-01 close report + …-schema.sql.

ADR-033 — The canonical client key is an opaque minted id; meaningful codes are attributes

Status: Accepted Date: 2026-06-07 Drafted from: live rto-ops-db (0692049c…) schema + counts verified this session (independently re-verified by Alex), plus Tim's framing decision. Re-verify load-bearing substrate at the implementing brief's Gate 1.

Context. ADR-026 established a single canonical client id, related across databases by id at the application layer; ADR-032 placed the apex client_files keyed by client_files.client_id. The substrate reality, verified 2026-06-07: that canonical id is today a human-readable slug (e.g. test-client-rtopacks-dev), used as the key in orgs.id, identity tier_grants.client_id, and client_files.client_id. The CRM clients table strayed off it entirely — it keys on a random-blob id plus rto_code TEXT UNIQUE, with no client_id column; its sole bridge to the rest of the model is the government code.

Two latent problems converge: 1. A meaningful string as the key inherits every way that meaning can go wrong. The government code is absent for whole client classes and can be reissued; the slug can go stale on rename, collide, or encode intent (test, dev). Either as the canonical key is the same class of fragility. 2. The client base is widening past registered RTOs. Intending RTOs (Type 2, pre-registration) have no government code yet; course-writing / non-regulated accounts (Type 3, and the UCCA.online direction) may never have one. rto_code therefore cannot be the spine, and rto_code is already being retired from orgs (the cued orgs identity-field retirement).

Decision. The canonical client key is an opaque, immutable, system-minted id, issued at client creation for every client regardless of type, carrying no business meaning. The form is an opaque prefixed id (recommended cli_<random>); the exact random form (UUID/ULID) is an implementation detail for the mint brief. (The cli_ prefix is a client-key discipline; it is not mirrored from users — ADR-024 canonises a bare RFC 4122 UUID for users. Clients and users have distinct id disciplines by design.)

All meaningful identifiers become attributes hanging off the key, never keys themselves: - rto_code (government code) — present for registered RTOs, NULL otherwise. An attribute, not a relation key. Registered RTOs are a subset of clients (those with a non-null rto_code), not the definition of a client. - the slug / human handle — a label; retired as a key. - a human-facing customer reference number (e.g. RTP-00001) — a display attribute for invoices and support, minted alongside the key. Because it is never load-bearing, a sequential/readable form is fine: the enumeration risk of sequential numbers never touches anything that keys data.

All cross-database relations key off the opaque client key, by id at the application layer (ADR-026). orgs, identity, client_files, clients (CRM), and future modules all reference it.

Consequences. - The three currently-clean records (orgs.id, identity tier_grants.client_id, client_files.client_id) are re-keyed from the slug to the opaque key. Data is trivial (2 orgs, 1 clients row, 1 golden record — all verified) but it touches every code reference to the id: a pre-customer migration — cheap now, a live migration once customers exist. - The CRM clients table is brought onto the opaque key (gains a client_id column; its random-blob id and rto_code-as-bridge retire as relation mechanisms). This is the CLIENT-RECORD-RECONCILIATION-01 work, now flowing from this ADR. - This ADR is an input to the prod-mint arc (CLIENT-FILE-PROD-MINT-MECHANISM-01): onboarding mints the opaque key (and the display number) as step one of client genesis. Settling the key here, before any real client is minted, is the point of doing it now. - Sequencing constraint: must land before, or fused with, the orgs identity-field retirement — that retirement removes rto_code from orgs, and rto_code is the CRM's only current bridge; re-key first or the bridge breaks mid-flight. - ADR-032's deferred Type-2/Type-3 evolution (nullable rto_code) is partly subsumed: with the opaque key as spine, a null rto_code is no longer a keying problem, only an attribute-presence fact. - Consistency where it applies: clients and users are both related cross-DB by id at the app layer (ADR-026). They do not share a key form — clients carry an opaque cli_… prefix minted at onboarding; users carry a bare RFC 4122 UUID minted at signup (ADR-024). The shared discipline is the cross-DB-by-id relation, not the prefix.

(Reconciled 2026-06-15: the original rationale claimed clients and users share a prefixed-opaque id discipline, citing a usr_… precedent. ADR-024 canonises a bare RFC 4122 UUID for users — the usr_… precedent did not exist. ADR-033's actual decision (opaque cli_… for clients) is unaffected; only the false symmetry with users is corrected.)

See also: ADR-024 (canonical user identity — bare RFC 4122 UUID); ADR-026 (cross-DB relation by id at the application layer); ADR-032 (client-file placement — defines client_files.client_id as the canonical id; this ADR redefines what that id is); client-spine.md §3 (three customer types — the subset logic) and §5 (apex client file); CLIENT-RECORD-RECONCILIATION-01 (brings clients onto the key); CLIENT-FILE-PROD-MINT-MECHANISM-01 (mints the key); the cued orgs identity-field retirement (sequencing constraint).

ADR-034 — Prod billing fails closed until go-live; no sandbox creds in prod

Status: Accepted Date: 2026-06-07 Drafted from: DEV-BILLING-SECRET-PARITY-01 close (staging webhook confirmed end-to-end — Stripe Resend → 2xx) + the verified prod-unwire this session. Re-verify the prod secret inventory at any billing-go-live brief's Gate 1.

Context. Dev internal-api (rtopacks-internal-api-dev, split off 2026-05-27) was never given billing secrets; prod internal-api held the full Stripe + QB set. The reconciliation across BILLING-CALLBACKS-ROUTE-AUDIT-01BILLING-WEBHOOK-SECRET-CONFIRM-01 surfaced a sharper truth than the webhook-503 symptom: prod's billing secrets were the one shared sandbox Stripe account and sandbox QuickBooks — no real production Stripe/QB account exists yet (no prod keys ever issued). So prod billing was not "not live yet"; it was silently live against test accounts — a real customer hitting that path would have transacted into a sandbox (fake invoices, no real money, prod ledger quietly accruing test rows). With dev now carrying the full sandbox set (DEV-BILLING-SECRET-PARITY-01), prod no longer needs sandbox creds for testing, and holding them was both redundant and a latent hazard.

Decision. Prod billing fails closed until go-live. Prod internal-api carries no Stripe/QB secrets by design; all prod billing paths — webhook intake and Stripe/QB API calls — fail closed until real production keys are set at go-live. Prod /billing/webhook503 "Webhook secret not configured" is the intended state, not a regression — do not re-wire prod with sandbox creds. Dev internal-api (sandbox creds) is the billing testing path.

Concretely: prod rtopacks-internal-api retains only INTERNAL_SOURCE_SECRET (binding auth) and SESSION_ATTRIBUTION_SECRET (not billing). The seven billing secrets — STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET, QB_CLIENT_ID, QB_CLIENT_SECRET, QB_COMPANY_ID, QB_REFRESH_TOKEN — are absent.

Consequences. - Fail-closed verified by behaviour, not just the secret list: prod /billing/webhook (both the billing-callbacks edge and the direct route) returns 503 with a stripe-signature header present. Dev, by contrast, is configured and verifying — it returns 400 "Invalid signature" on a bad-signature probe (negative control) and 2xx on a real signed event (end-to-end proof, confirmed via Stripe Resend). - Go-live becomes a deliberate, explicit act — setting real production Stripe + QB keys on prod — that cannot happen by accident or by inheriting sandbox state. - Mechanically reversible but explicitly not to be casually reversed: the sandbox values live in 1Password (RTOpacks vault), so re-wiring is trivial — but doing so re-creates the silent-test-account hazard and is disallowed by this ADR. - The prod Stripe webhook destination will log the (now-expected) 503s until paused Stripe-side — operational noise, not a fault. - Dev is the sole billing testing path; sandbox test events record only against dev's ledger (rto-ops-db-staging).

Relationship to ADR-027 (environment parity). This is a deliberate, documented asymmetry, not parity drift: billing credentials are excepted from env-parity by design until go-live, precisely because parity here would mean prod silently inheriting sandbox. Structure stays parity'd (same worker, same routes, same handlers); only the credential presence differs, and that difference is the safety property.

See also: ADR-031 (channel separation — the billing-callbacks edge → internal-api service binding the webhook traverses); ADR-021 (plan/entitlement/metering as substrate concerns); ADR-027 (environment parity — the documented exception above); DEV-BILLING-SECRET-PARITY-01 close report; BILLING-CALLBACKS-ROUTE-AUDIT-01 + BILLING-WEBHOOK-SECRET-CONFIRM-01 (the audits that reconciled the symptom); memory project_billing_env_split_2026_06_07 (the operational-trap note).

ADR-035 — Token conformance is a canonical commitment: themeable values resolve through design tokens, never literals

Status: Accepted Date: 2026-06-08 Drafted from: SETTINGS-MODULE-AUDIT-01 Gate 1 (2026-06-08) — Alex's traced mechanism + bytes: every Experience control routes through useEnvironment().update()applyPrefs(), which writes data-theme / data-contrast / size + motion hooks to :root; the source globals.css has zero custom rules for any of those hooks. Plus foundation §1.2–§1.4 (dark/light/high-contrast token sets), §8.1 (ThemeManager + OS fallback), §8.3 (light-mode video behaviour), §21 (glass is a dark-mode-only concept). Unverified and load-bearing: the extent to which components hold hardcoded literal values instead of consuming tokens is not yet measured — that measurement is the remediation brief's Gate 1 and sizes the sweep.

Context. The design system already specifies theming end to end. §1.2 defines the default dark token set; §1.3 the light set (:root[data-theme="light"]); §1.4 the high-contrast overrides (:root[data-contrast="high"]). §8.3 defines the surface behaviour that accompanies light — the video background is replaced by a static gradient or solid and the loop stops ("light mode implies document-like focus"). §21 defines that glass is dark-mode-only — in light mode surfaces become solid --color-bg-elevated with a border, no translucency. §8.1 has the ThemeManager write the root attributes with an OS prefers-color-scheme fallback, so light / dark / system is canonical intent.

None of it was implemented. The settings audit found the apply layer absent: the controls persist a preference, applyPrefs sets the correct root hooks, and nothing in globals.css responds. Light mode, high contrast, and text size do nothing. The accessibility controls are the worst of it — the surface that is meant to be the product's equal-access provision is, today, a facade.

Two gaps converge, and either alone defeats the goal: 1. The specified override blocks were never written into code. §1.3 and §1.4 exist as canon; their CSS does not exist in globals.css. 2. Themeable values are suspected to be hardcoded in components rather than drawn from tokens. A surface built with no light or high-contrast block to respond to has had no forcing function to reference the tokens — so a literal #0d1117 will not flip when the token set changes. This defeats theming and accessibility: a hardcoded value cannot answer a contrast or theme override. The extent is unmeasured (see Drafted-from).

The deeper point is structural: theming and accessibility are not features you add — they are conformance properties of the token layer. They exist if and only if every themeable value resolves through a token. This is the same class of commitment as environment parity (ADR-027): a structural property the whole surface must hold, not a behaviour bolted onto one screen.

Decision. Every themeable value in surface code resolves through a design token (a CSS custom property), never a literal. A hardcoded themeable value is a defect.

Themeable axes in scope: - Colour — backgrounds, text, borders, interactive, status, KN, brand/launcher palette. The primary and most load-bearing case. - Surface depth — the three-level base/elevated/card stack (§8.2/§8.3). - Type scale — text-size responds to a root scale, not per-component font sizes. - Motion — reduce-motion is honoured through the token/attribute layer, not only the OS @media.

The three themes (dark §1.2 default, light §1.3, high-contrast §1.4) and the size/motion axes are delivered solely as token overrides at the document root (:root[data-theme=…], [data-contrast=…], etc.), with the accompanying surface behaviours implemented as specified — §8.3 (video → static + stop in light) and §21 (glass → solid in light). Light / dark / system is first-class, per §8.1/§8.3.

The admin console remains light-only by design (§13.8): it is still token-based, it simply does not receive the dark set. Token conformance is universal; which token sets a surface receives is a per-surface decision.

Consequences. - A one-time tokenisation remediation sweep. Hardcoded themeable literals are replaced with token references across the surface, sized by a measurement audit (the remediation brief's Gate 1). Pre-customer, this touches many files at zero user risk — the cheapest it will ever be, and the reason to do it now. - The apply layer is authored. §1.3 (light) and §1.4 (high-contrast) blocks, plus the text-size scale and reduce-motion rules, land in globals.css. At that point the existing controls become live with no change to their wiring. - The accompanying surface behaviours are implemented in the same work: §8.3 video pause + static gradient/solid in light (the pause is already half-wired), §21 glass → solid in light. - A standing review gate makes the decision durable. Literal colour values (hex/rgb()) in component styles are flagged in review — and, where practical, by lint/CI — as defects. New code uses tokens. Without the gate this is a one-off cleanup that re-rots; with it, the conformance property holds going forward. - Accessibility conformance becomes deliverable and verifiable — the equal-access obligation the Experience controls represent — where today it is a facade. Verification is empirical: each control flips the whole surface, contrast meets the bar, motion genuinely stops. - The settings-module build sequences downstream of this. The live-preview drawer and the admin mount are only meaningful once the controls flip the surface; they follow the remediation, they do not precede it.

See also: foundation §1.2–§1.4 (dark/light/high-contrast token sets), §8.1 (ThemeManager + OS fallback), §8.3 (light-mode video behaviour), §21 / §21.x (glass is dark-mode-only); SETTINGS-MODULE-AUDIT-01 (the finding); ADR-027 (environment parity — the sibling "structural conformance is canonical" commitment); the forthcoming token-conformance remediation brief (carries the measurement at Gate 1) and the settings-module build (drawer + admin mount, downstream).

ADR-036 — User preferences are identity-scoped state; device-local storage is a cache, never the source of truth

Status: Accepted Date: 2026-06-08 Drafted from: SETTINGS-MODULE-AUDIT-01 (2026-06-08) — Experience prefs (rto-theme, rto-text-size, rto-contrast, rto-reduce-motion, rto-icons, kn-enabled) persist to localStorage only, plus a cookie for video-intensity; workspace_prefs (server D1, /api/prefs) is identity-keyed and already persists active_mode, annotation_states, active_tabs. The workspace_prefs table was independently verified this session — (user_id, client_id) PK, schema identical dev/prod. Plus Tim's product decision. Verify the exact current schema at the implementing brief's Gate 1.

Context. The product is identity-centric at every layer (ADR-020 tiers; canonical identity). Consistent with that, some user preferences already persist server-side, keyed to the user, in workspace_prefs. But the Experience preferences — theme, text size, contrast, motion, icons, KN — landed in localStorage instead: per-browser, per-device, per-origin. The consequences: a preference set on one device does not follow the user to another, and nothing carries across origins from my.rtopacks.* to admin.rtopacks.*.

Two things make that the wrong home. First, it is incoherent with an identity-centric product — a second device that looks different reads to a user as broken, not as device-specific, and the broken-looking thing is the product. Consistency is the product keeping its promise. Second, a person's access needs are properties of the person, not the device: if you require reduced motion or higher contrast, you require it on every screen. Tying those to a device is the wrong axis. Genuine device-level sizing is what OS and browser zoom already provide.

Decision. User preferences are identity-scoped state: persisted server-side, keyed to the user (workspace_prefs or its successor), authoritative and consistent across every device and surface. There is one preference per person — change it anywhere, it applies everywhere.

Device-local storage (localStorage / cookie) is permitted only as a first-paint cache — to avoid a flash-of-default while the authoritative value loads — and is reconciled from the server on load. It is never the source of truth.

New preferences are identity-scoped by default. A preference whose home is localStorage is a defect — the same class of standing rule as ADR-035's token gate.

Consequences. - The Experience prefs migrate from localStorage to workspace_prefs (the EXPERIENCE-PREFS-IDENTITY-01 brief). useEnvironment reads/writes the server as source of truth; a cookie/local cache hydrates the paint-critical prefs (theme, contrast) so first paint is correct — video-intensity already uses a cookie exactly this way, so the pattern is partly in place. - Cross-device and cross-surface consistency follows: settings follow the person, including into the admin console once the settings module mounts there. - Trade accepted, eyes open: no true per-device preferences. Device-level sizing defers to OS/browser zoom; person-level access needs (contrast, motion) correctly travel with identity, which is where the need lives. - A standing review gate going forward: a new pref persisted to localStorage as its home is flagged as a defect. Pairs with ADR-035 — together, settings render correctly and follow the person. - Sequences after the token-conformance rendering work. The two are orthogonal (rendering vs persistence) but both touch useEnvironment/settings, so they run one at a time, not in parallel.

See also: ADR-020 (identity tiers — the identity-centric model this extends); ADR-035 (token conformance — the sibling conformance commitment and standing-gate shape; 035 makes settings render, 036 makes them follow you); SETTINGS-MODULE-AUDIT-01 (the localStorage finding); foundation §8.1 (ThemeManager + OS fallback); EXPERIENCE-PREFS-IDENTITY-01 (the migration brief).

ADR-037 — Workspace theme model: a dark immersive shell over light document-like work surfaces

Status: Accepted Date: 2026-06-08 Drafted from: SETTINGS-MODULE-AUDIT-01 + TOKEN-CONFORMANCE-REMEDIATION-01 Gate 1 (the inversion finding) + the Stage A blocker — Alex's source read of globals.css: :root is light-default (UI-RESET-01), dark exists only as local scoping on the immersive surfaces, interactive is teal #0e7490 not §1.3's blue. Confirmed by Claude's live verification this session on dev (logged in as the test client): the launcher renders dark/immersive, Studio renders light/document-like. The exact shipped token values are transcribed from globals.css at FOUNDATION-THEME-RECONCILE-01 Gate 1 — canon catches up to verified reality, not the reverse.

Context. Foundation §1.2/§1.3 describe dark as the :root default with light as a [data-theme="light"] opt-in, on a GitHub-Primer-derived palette (the literal Primer hex, including blue #0969da). The product does not follow this and has not for a while. globals.css :root is light (the UI-RESET-01 migration — the dark tokens "broke any component styled against var(--color-bg-base)", so each was mapped to its light equivalent); dark survives only as local scoping on the immersive surfaces (launcher, AdminHeroFrame). Live: launcher dark, Studio light, interactive teal.

So the product is a hybrid the foundation doesn't describe — a dark immersive shell and launcher wrapping light, document-like work surfaces. This is not drift to be reversed: it aligns with the foundation's own §8.3 ("light mode implies document-like focus"). §1.2/§1.3 are the stale artefact, not the implementation. Reversing the implementation to match them (the rejected "Option A") would darken every work surface to satisfy a spec the product deliberately left behind.

Decision. The canonical workspace theme model is a dark immersive shell over light document-like work surfaces: - Light is the work-surface base — the :root default. Studio, People and all work surfaces render light/document-like by default. - Dark is an override — selected by the user theme toggle (a dark work mode) — and is also the fixed local treatment of the immersive surfaces (launcher, hero frames) regardless of the toggle. The immersive shell is its own dark, video-backed context, not a user theme. - Teal #0e7490 is the canonical interactive/brand colour. The foundation's Primer blue is a superseded draft value. - High contrast is authored for both bases. §1.4 as written assumes a dark base (white text) and is illegible on the light work surfaces; light-base and dark-base variants are both required. - globals.css is the runtime source of truth for token values; the foundation §1 tables are the decision-of-record that mirror it — the §1.10/§1.11 precedent (code is runtime source, the table is the record).

Consequences. - Foundation §1.2/§1.3/§1.4 and §8.2/§8.3 are reconciled to this model in FOUNDATION-THEME-RECONCILE-01: §1.2 becomes the light work-surface base (mirroring the shipped :root), §1.3 the dark override (mirroring the shipped dark-scoping block), §1.4 high-contrast for both bases, teal canonical. Values transcribed from globals.css, verified at that brief's Gate 1. - TOKEN-CONFORMANCE-REMEDIATION-01 Stage A was correctly blocked and now proceeds against the reconciled canon — the apply layer authors [data-theme="dark"] as the override (not light), and high-contrast for the actual base. - The user light/dark toggle themes the work surface; the immersive shell stays dark. Whether the shell ever follows light per §8.3's static-video behaviour is a later, separate refinement — not committed here. - No visual change to the shipped product. This records what is already on screen so the token-conformance cleanup builds on an accurate foundation rather than a stale one. - Pairs with ADR-035 (render via tokens) and ADR-036 (prefs follow identity): 037 settles what the themes actually are. Together they make the settings real, portable, and accurate. - Canonises UI-RESET-01 rather than leaving it an undocumented deviation.

See also: foundation §1.2–§1.4 (the token sets being reconciled), §8.2/§8.3 (dark/light specifics + the document-like-focus rationale this aligns with), §1.10/§1.11 (code-is-runtime-source precedent); ADR-035 (token conformance), ADR-036 (identity-scoped prefs); SETTINGS-MODULE-AUDIT-01 + TOKEN-CONFORMANCE-REMEDIATION-01 (the findings); FOUNDATION-THEME-RECONCILE-01 (the §1 reconciliation this drives).

ADR-038 — org_memberships folds into the canonical grant record; billing stays in ops

Status: Accepted Date: 2026-06-09 Drafted from: live rto-ops-db-staging org_memberships schema + deployed rtopacks-internal-api-dev usage (14 references), verified read-only this session; the decision is Tim's, taken after weighing the downsides. Re-verify load-bearing substrate at brief 5's Gate 1.

Context. ADR-024 consolidated user identity into three canonical tables (users + tier_grants + credentials) in rto-identity-db, retiring six L3-truth sources. org_memberships (in rto-ops-db) was not in that retirement list and was not named canonical — a seventh source the consolidation didn't catch. The substrate reality, verified this session:

  • org_memberships is the live operational workhorse: 14 references in internal-api, read and written — member CRUD, seat counting, is_primary_admin, status lifecycle, and the passkey-status join. Its columns: id, org_id, user_id, membership_type, role, site_id, is_primary_admin, billing_tier, billing_status, invited_by, invited_at, activated_at, deactivated_at, status, created_at. It is keyed by email (user_id = email in the current dev fixture), not the canonical UUID.
  • tier_grants is the canonical identity/grant record (UUID-keyed; tier, client_id, granted_at, granted_by, revoked_at). Read-only in internal-api here; the canonical members read already exists at /identity/members (tier_grants ⋈ users, named members + pending portal_invites).

The problem is duplication: both tables answer "who belongs to this org," keyed differently, free to drift. org_memberships's columns also span four concerns — membership existence, tier/role, billing, and lifecycle — most of which now have canonical homes.

The full-decompose option was weighed and its downsides taken seriously: loss of a single membership object, loss of site_id (site-scoped membership, material at multi-site/TAFE scale), muddying the identity/operational boundary, and a large migration across 14 live sites. The decisive factor is timing: pre-revenue, the data migration is trivial (one fixture row), so the risk that makes such consolidations expensive — moving live customer data — does not exist. This is the cheapest moment to reach the right shape (build-it-right-once).

Decision. tier_grants becomes the single canonical record of org membership and tier. org_memberships is retired, its concerns sent to their canonical homes:

  • Membership existence + tier → a tier_grants row is the membership. membership_type/role fold into tier (confirm equivalence at brief 5 Gate 1; if role carries anything beyond access-tier, preserve it explicitly — do not silently lose it).
  • is_primary_admin → a tier_grants flag (e.g. is_primary on the T4 grant). It is a grant-level designation, consistent with tier_grants being the user↔org grant record.
  • site_id → a tier_grants field — the grant may be site-scoped. This preserves site-level membership rather than dropping it.
  • Lifecycle → already native to the grant record: invited_bygranted_by; invited_at/activated_atgranted_at; deactivated_atrevoked_at (ADR-023 deactivate-not-delete). status is derived, not stored: pending = an open portal_invites row (no grant yet); active = a non-revoked grant; deactivated = revoked_at set.
  • Billing (billing_tier, billing_status) → stays in ops (ops-db, where org billing already lives). Identity-db must not carry financial data — this is a deliberate domain wall (HARD SEPARATION / OPS-SURFACE adjacency), held regardless of migration convenience. Per-member billing is redundant with per-org subscription + seat count and drops.

The members list read repoints to the canonical /identity/members (tier_grants ⋈ users) — named members, the "members-name join" the surfaces have been missing. All org_memberships reads/writes repoint to the canonical homes. The record is UUID-keyed throughout (the email→UUID rekey, via id_migration_map, lands as part of this).

user_tenant_roles (rto-workspace-db) is also retired here — zero internal-api references (confirmed), already named retired under ADR-024.

Consequences.

  • A clean migration touching ~14 internal-api call sites + the admin members/seat surfaces. Data is trivial (pre-revenue) — the work is code, paid once now. This is the substance of brief 5.
  • The membership-existence drift is closed: one source (tier_grants), one keying (UUID). The two-list ambiguity (getMembers vs getMembers2) resolves to one canonical read.
  • tier_grants gains is_primary + site_id columns (both env twins, ADR-027 parity). It remains the identity/grant record; it does not gain billing.
  • Federation-leanness trade-off, recorded: ADR-025 forward-positioned the identity store for possible federation; folding is_primary/site_id in makes it slightly less lean. Judged acceptable — the additions are grant-relational, not foreign concerns, and if federation becomes real, extracting operational grant-attributes is a contained change, not a customer-data migration. Billing staying out is the load-bearing part of keeping it lean.
  • Composes with, and is sequenced alongside, ADR-033 (slug → opaque cli_… client key): both are the keying reconciliation. They are distinct decisions — ADR-038 is who owns membership, ADR-033 is what the client key is — but land in the same arc (brief 5 / CLIENT-RECORD-RECONCILIATION-01), and brief 5 sequences them so neither breaks the other mid-flight.

See also: ADR-024 (identity consolidation — this extends it to the seventh source); ADR-023 (deactivate-not-delete → revoked_at); ADR-026 (cross-DB relations by id at the app layer — tier_grants in identity-db, billing in ops-db, joined via internal-api); ADR-033 (the opaque client-key rekey, sequenced with this); HARD SEPARATION RULE / OPS-SURFACE-RULE in standing-rules.md (billing stays in ops); id_migration_map (the email→UUID rekey vehicle); CLIENT-RECORD-RECONCILIATION-01 (brief 5 — where this lands).

ADR-039 — Client-assertion overlay over regulator-verified mirror data

Status: Accepted Date: 2026-06-10 Drafted from: CLIENT-CORRECTIONS-OVERLAY-01 (dev 77ab50ac) — the verified overlay build + Gate-4 dev verification (assert/withdraw, server-side compose with per-field provenance, regulator value preserved + recoverable, audit byproduct, mirror untouched on re-pour); extended by CLIENT-CONTACT-CORRECTION-01 (dev 2d592f0d) to the primary-contact group (decomposed scalar field_keys, per-field isolation verified). Re-verify the overlay substrate at any consuming brief's Gate 1.

Context. - The client record (rto-client-db) is a pure mirror of regulator-sourced data: client_files apex + the rto_* tables, all provenance='regulator-verified', periodically re-poured from Pith (rto-nrt-db). Edits written in place are clobbered on the next re-pour. - Clients still need to correct or annotate fields that are wrong or stale at the regulator source — a changed phone, a preferred contact email — without waiting for the regulator record to catch up. - Precedent already existed: studio_proposed_scope (workspace-db), a client-asserted overlay sitting beside regulator scope data and never mutating it.

Decision. - Client corrections are a read-time overlay, stored in workspace-db (not the mirror), keyed by org_id (== client_id == orgs.id, ADR-026), and composed server-side at render: the regulator value is the base, an asserted correction overlays it, and the regulator value stays visible and recoverable. Never a silent overwrite — interface-as-revelation, truth-before-paperwork. - Corrections are withdrawable, not deleted (ADR-023): withdraw sets lifecycle_state='withdrawn', the row is retained, and the field reverts to the regulator value. - Substrate: client_field_corrections (workspace-db) — id (PK), org_id, field_key, value (scalar TEXT, the client-asserted value), asserted_by (session user id), lifecycle_state ('asserted' default | 'withdrawn'), created_at, updated_at, UNIQUE(org_id, field_key); index on (org_id, lifecycle_state). One row per (org_id, field_key) — state toggles on withdraw/re-assert (upsert), the row is never deleted. Provenance is derived, not stored: a live 'asserted' row means client-asserted; the regulator value is read from the mirror at compose time and never copied onto the overlay. - field_key is a flat scalar namespace. Composite entities decompose into dotted scalar keys (primary_contact.email, …) — never JSON-in-value. Keeps the overlay uniformly scalar; every field is independently correctable and independently recoverable. - Authz: T4 admin on its own client_id only. T3 operators (NULL client_id) cannot assert — the write path 401s before any scope read. Writes are audited as a byproduct (platform_audit_log interim → rtopacks-audit-* per ADR-029, provisioned 2026-06-15). - The mirror is never written. A re-pour cannot disturb a correction; a correction cannot disturb the mirror.

Consequences. - Two substrates of record per field: regulator (mirror, authoritative-by-source) and client-asserted (overlay, authoritative-by-intent). The composed view shows both; the client owns which is displayed. - Consolidation trigger (load-bearing): this is the second client-assertion overlay — studio_proposed_scope first, client_field_corrections second. Per the spine consolidation rule, the third instance must consume a shared overlay convention/module rather than copy the pattern a third time. Most likely third: scope-status corrections. Flag this when it appears.

See also: ADR-026 (cross-DB relations by id at the app layer — org_id == client_id, compose-at-read); ADR-023 (deactivate-not-delete → withdraw, row retained); ADR-029 (the canonical rtopacks-audit-* activity stream — the audit interim's eventual target); studio_proposed_scope (the first client-assertion overlay, the precedent); the spine consolidation rule in standing-rules.md (the third-instance trigger); CLIENT-CORRECTIONS-OVERLAY-01 + CLIENT-CONTACT-CORRECTION-01 (the builds this records).


Status: Accepted Date: 2026-06-10 Drafted from: the person-data decisions pass (2026-06-10), grounded on byte-true substrate (pc_persons, pc_credentials, users, tier_grants verified read-only via D1) and the CLIENT-RECORD-RECONCILIATION-01 Gate-3/5 verification. Execution lands in the merged seat-provisioning + person-data run; no code lands here.

Context. A person who signs off assessment executes a binding instrument; the binding party must be their legal identity, and that legal name must tie to the credentials authorising them. The system holds two unconnected "person" notions: the auth identity (users, identity-db — a human who can log in) and the workforce-compliance record (pc_persons, workspace-db — a trainer/assessor/contractor/expert who may have no login). The identity ADRs (024/025) govern the former and say nothing of the latter; there is no link between them in substrate, and a legacy people table (4 rows) shadows the canonical pc_persons. Without a canonical model the legal name risks being homed twice — the exact ambiguity to avoid on a binding document.

Decision.

Two concepts, two masters, never merged. - users (identity-db) is the auth identity — one row per human platform-wide (email UNIQUE). It exists iff the human can log in. users.id is the platform's only one-per-human anchor across all clients. - pc_persons (workspace-db) is the workforce-compliance record — org-scoped (one row per human per org). It exists iff an RTO needs the human on its register. The same human at two RTOs is two pc_persons rows.

Cardinality. users 1 → 0..N pc_persons, at most one per org (partial unique on (org_id, user_id) where user_id IS NOT NULL). pc_persons → 0..1 users.

The link. A nullable user_id on pc_persons — a cross-DB soft reference to users.id (documented as a column comment per ADR-026; integrity application-layer; multi-DB reads via internal-api). Unlinked persons (register trainers with no login) carry user_id IS NULL. This single column is also the portability conduit (ADR-041).

Name-resolution rule (single, platform-wide). pc_persons.full_legal_name is the only home of the legal name; every binding or audited artefact resolves it from there and nowhere else. pc_persons.preferred_name is friendly display within People (falls back to legal name). users.display_name is the auth fabric's own name (shell chrome, login greeting, platform-audit attribution) and never appears on a compliance artefact. No sync machinery between them — different facts, different masters.

Entry is a deliberate act in either direction — no auto-materialisation. Seat provisioning (a T4A grant) does not auto-create a pc_persons row: not every seat-holder is workforce (a billing-only admin is a seat, not a registrant), and the NOT NULL employment_type/role constraints are correct — they force the human who knows the compliance-relevant facts to supply them rather than the system inventing them (truth-before-paperwork at the schema level). The link is set as a T4 act — from People ("this person has a login" → admin links the user) or from member management ("add this member to the register" → the create-or-link flow collects employment_type/role).

Find-or-link is the only path; matching is admin-asserted. A People-first contractor who later holds a seat is linked, never duplicated. The match between a register person and a login is asserted by the T4 admin (the human closest to the truth of "this login is this trainer"). USI is never a join key (it is a consent-bound verification credential, not an identifier to silently match on). Email is a non-binding suggestion in the link picker only — never stored as a join key (a second home for the email fact would drift).

Canonical substrate. pc_persons + its satellites (pc_credentials, pc_cpd, pc_licences, pc_professional_experience, pc_supervision, pc_unit_competencies) are canonical. The legacy people and people_tae tables retire (MIGRATION-COMPLETION-DISCIPLINE), as part of the person-data run, after confirming no live read of people remains.

Consequences. - The legal name is single-homed; the legal-name capability is the resolution rule above, not a name-mirroring mechanism. - One human at two RTOs is two pc_persons rows sharing one users.id — no ambiguity, and no universal-person table. - T3 operators (NULL client_id) get no pc_persons — they are not org persons. - A wrong manual link is legible and correctable (an attributed T4 act); a wrong automatic match would silently contaminate the audit chain — which is why matching is asserted, not inferred. - users.client_id (singular) is in tension with the multi-client grants tier_grants admits; reconcile its meaning in the membership arc. Nothing here depends on the outcome — all person-anchoring is on users.id.

See ADR-024, ADR-025, ADR-026, ADR-038, ADR-041, ADR-042; PC-SPEC-01. Execution: the merged seat-provisioning + person-data run.


ADR-041 — Person-data ownership seams; no person-core database

Status: Accepted Date: 2026-06-10 Drafted from: the person-data decisions pass. This overturns the v2 think-handover's lean toward a dedicated person-core DB, on applying the build-conduits-not-rooms discipline strictly.

Context. A person's record spans data of very different ownership: their own portable identity; credentials (person-owned but RTO-verified); the RTO's proprietary relationship with them; and documents. Forward-pressures — a contractor across multiple RTOs, a future self-presentation/portability surface — raised whether the dense, regulated identity should get its own database now. Organising person data by topic (the natural shape of a UI) is the wrong cut for database boundaries: it still yields one fat per-RTO record sliced by subject, which does not prevent a painful future migration.

Decision.

Cut person data by ownership and portability, not topic — four ownership classes: 1. portable-self — person-owned, consent-portable: legal name, preferred name, USI, government-ID verification status. 2. person-credential — person-owned claim (WWCC/Blue Card, police check, quals, licences), portable in principle; the verification of each is relationship-level (class 4). 3. rto-relationship — org-proprietary, never travels: allocation to this RTO's units, CPD against this scope, supervision, internal notes, employment. 4. verification — the RTO's act upon a claim; T4-authored only (ADR-042).

Implemented as labelled seams within workspace-db, not a physical re-cut. No person-core database is built. ADR-025's identity-db earned a dedicated database for a binding-surface security property (customer-facing workers structurally cannot reach it) and a federation boundary. The portable self has neither today — it is data the org's own register surfaces read on every page load, and even the "portable" fields are per-org copies (each RTO verifies its own). A person-core DB holding per-org copies is workspace data with extra steps; one holding one-row-per-human is the universal-person room we are not building. The conduit is instead: - Ownership labels on column groups. Each pc_persons/satellite column declares its ownership class as a schema comment (extending the ADR-026 comment convention to an ownership axis). Classification is a hard requirement, enforced at review — a column that cannot be classified is a design smell to resolve before it lands. The labels are what convert a painful future extraction into a contained one; they must stay true, so labelling is a review-gate item, not optional documentation. - users.id is the portability conduit, and it already exists. Portability is consent-gated; consent requires the person acting; acting requires a login; a login is a users row. Linked pc_persons rows across orgs already share users.id (ADR-040). No new substrate, no global person id.

Verification is a column-group snapshot, not an events table. Each claim table carries verification_method/verification_date/verified_by — the current-state snapshot, matching the live schema and the spec's last-verified semantics. The history of verification acts lives in the audit stream (ADR-029 pattern). What makes the assert/verify seam real is the write-path separation in ADR-042, not table separation. A first-class verification-events table is additive later if Phase 2's audit-intensive cases (e.g. element-level evidence) demand it.

Documents stay pointers (Record/Document Manager per spec); the pointer record carries the ownership class of the document it points to.

Consequences. - A dedicated person-core DB becomes warranted only when rows become genuinely cross-tenant (real portability) or a regulated-PII isolation requirement arrives; at that point the extraction is a copy-and-repoint (columns are ownership-labelled and anchored on users.id), not a semantic row-split. - DOB sits in the portable-self class conceptually but is in neither the spec nor the schema; it is deferred to the Phase 2 USI brief (OSIR lookup will likely need it). No speculative columns.

See ADR-025 (the precedent this declines to over-apply), ADR-026 (comment convention extended), ADR-029 (audit stream), ADR-040, ADR-042. Execution: the person-data run.


ADR-042 — T4B: the person-tier for self-service record authorship

Status: Accepted Date: 2026-06-10 Drafted from: the person-data decisions pass; extends ADR-020's tier model. The structural slot (enum value) is minted in this arc; T4B surfaces and flows are built at People Phase 2.

Context. In VET, individuals must keep their own credentials and currency current — more than in most professions. Letting the person maintain their own record improves data quality and is a network primitive, but it must not let an operational user (e.g. a course builder) doctor someone else's compliance to serve their own outcome, nor let a person self-certify their own compliance. ADR-020 set three tiers (T3/T4/T4A) and noted the numbers are not load-bearing but the structural distinctions are.

Decision.

T4B is a tier — a new tier_grants.tier enum value — not a flag or permission-shape on T4A. A self-service login scoped to the People module, own-record-only; uncharged and not seat-counted. Two structural distinctions decide tier over flag: 1. Seat-counting must be structural, not conditional. T4A is defined as the countable seat consuming plan entitlement (ADR-020/021); an "uncharged T4A" contradicts the model's own terms, and a flag would mean every seat-count read must remember to filter it — one missed filter overbills or wrongly blocks. ADR-038's billing wall is the precedent: financial semantics get hard cuts. "Count T4A grants" must stay unconditionally true. 2. Different master. T4A serves the RTO's operations; T4B serves the person's own record (the fabric-vs-record distinction, ADR-040/041). Its authority — own-record-only — is not a subset drawn from the T4 admin's permission pool the way T4A shapes are; a tier value is less machinery than inventing a row-scoped permission concept nothing else needs.

The numbering preserves continuity, not strict hierarchy — T4B is not a rung below T4A but a different axis (the person's own self-service). Tier-proliferation guard: a new tier requires a distinct (charging semantics × authority master) pair and must classify as a tier grant, not an issuance gate (ADR-024's classifier). T4B qualifies; students do not arise (separate domain); third-party trainers are an employment type on the register, not a tier.

Schema ripple, scheduled. tier_grants.tier CHECK widens to ('T3','T4','T4A','T4B'); impersonation_tokens.target_tier to ('T4','T4A','T4B'). Both are D1 table rebuilds — sequenced to ride the arc's existing tier_grants rebuild so the spine table is rebuilt once, pre-revenue, not again post-revenue to add a value free to add now. Until People Phase 2 the issuance gate is closed (nothing mints T4B) and the session layer rejects T4B sessions; the grant value exists. Charging posture (free forever vs eventual pricing) is commercial — out of canon's scope. (Executed: TIER-CHECK-T4B-WIDEN-01, Brick 2.5, dev+prod 2026-06-14, commit 1b9388e8 — both CHECKs widened, grants preserved, issuance/session gate held closed.)

Assertion ≠ verification — hard rule, by write-path separation. The subject (or an operator on their own record) asserts/maintains claims; only T4 verifies; neither write path may touch the other's column group. Distinct endpoints — assert (subject = own linked record) and verify (T4 only; verified_by is always the verifying session's user — no proxy verification). Verification columns are the current-state snapshot (ADR-041); the acts are distinct audit-stream event types. Self-verification (a one-person RTO's T4 verifying their own credential) is structurally unavoidable and therefore permitted, but legible: verified_by resolving to the subject's own linked user is visible on the audit export — permitted, audited, never hidden.

Edit-authority matrix.

Action T4B (record owner) T4A (operator) T4 (admin)
Edit own record — assertion fields yes yes (only if linked) yes
Edit another person's compliance data no no (cannot fudge to serve a course) yes
Verify any record no no yes (self-verification legible, above)
Create a stub / reference a person yes (unverified, Studio pattern) yes

Option A/B dissolves — convention PEOPLE-REGISTER-AUTHORSHIP. The People register is and stays a single org-scoped substrate (the spec's Option A). "Option B" (staff own their profile) was never a different data model — it is Option A plus T4B own-record write paths, with the verification monopoly held by T4. The Phase 1 data model therefore already supports B, satisfying the spec's "do not foreclose B." The People spec adopts this convention by name in its (currently absent) permission-model section.

site_id does not scope register visibility in Phase 1. The People data model has no site dimension (state_of_operation is a WWCC driver, not a site). If multi-site RTOs later need site-filtered register views, that is a filter, not a wall — a People-spec permission-shape addition, deferred.

ADR-039 relationship. Person-assertion does not trip ADR-039's third-overlay consolidation trigger: the overlay pattern is base-value + overlay + compose-at-read over mirror data, whereas here there is no base value — the claim is the record, and verification is a status upon it. The family resemblance ("assertion/verification provenance") is named here so the consolidation rule sees it without being wrongly invoked.

Liability. T4B is clean authorship — it strengthens the evidentiary chain and removes internal-tamper vectors. It does not transfer the RTO's regulatory accountability: it remains the RTO, not the individual, that must demonstrate compliance (the spec's governing principle). The RTO still verifies and accepts everything.

Consequences. - The spine auth table (tier_grants) is rebuilt once, pre-revenue, carrying the widened CHECK alongside its is_primary/site_id columns. - The federation-leanness trade (ADR-038) holds — tier_grants gains a tier value, not a foreign concern.

See ADR-020 (tier model extended), ADR-021, ADR-024 (CHECK enum + issuance-gate/tier-grant classifier), ADR-029, ADR-038 (billing-wall precedent), ADR-039 (non-trigger), ADR-040, ADR-041. Execution: structural mint in this arc; surfaces at People Phase 2.


ADR-043 — TGA-grounding of person credentials and RTO scope

Status: Accepted Date: 2026-06-10 Drafted from: the person-data decisions pass; the carve-out corrects an over-broad "never free text" framing.

Context. A future workforce-currency engine (Phase 3 in PC-SPEC-01) reasons over what a person can teach and when their currency lapses — push (a new qualification surfaces teachable units and scope opportunities) and pull (a superseded training product surfaces currency decay, so the RTO never delivers against a lapsed qualification). Both are graph walks over the TGA corpus (rto-nrt-db, read-only Pith, sacred rows). They only work if the relevant data is grounded in corpus references, not free-text strings. But real trainers also hold credentials the corpus does not govern (university degrees, vendor certificates, overseas qualifications), so a blanket never-free-text rule would break on the first such credential.

Decision. Any field carrying a training-product identitypc_credentials.qualification_code, pc_unit_competencies unit codes, scope rows, "holds equivalent qualification" competency-basis references — is either: - a validated reference into the rto-nrt-db corpus (cross-DB soft ref per ADR-026; validated at write time by the mediating worker), including superseded codes — supersession is the point; trainers legitimately hold superseded products (e.g. TAE40110) — or - explicitly classed non-TGA (university/higher-ed, vendor certificates, overseas qualifications — a distinct credential class, marked as such, free-text descriptor permitted within that class only).

What is prohibited is the middle — an unvalidated string wearing a TGA code's clothes. The Phase 3 engine is not built here; this rule is the entire conduit: with codes grounded, push and pull are additive corpus walks; with free text, they are a re-plumb.

Consequences. - The corpus must carry supersession/currency edges for the pull engine. Whether the 15,128 enriched rows already carry them is a re-verification item; if absent, the edges land as a Pith sync-pipeline addition (TGA publishes supersession mappings) — an addition, never a mutation of sacred enriched rows (KN-sacred rule respected either way). - Write-time referential validation lives in the issuing worker (application-layer integrity, ADR-026), not the substrate.

See ADR-002 / ADR-004 (the sacred corpus / pristine substrate), ADR-026 (application-layer cross-DB integrity), PC-SPEC-01 (the Phase 3 engine this enables, explicitly not built). Execution: enforced on the People write paths in the person-data run; the engine is later.


ADR-044 — The Identity Entry Path: self-authoring gate + attestation

Status: Accepted Date: 2026-06-14 (accepted 2026-06-14; amended 2026-06-14 — entry-path design pass) Relates to: ADR-020 (access-control tiers), ADR-040 (soft-link, admin-asserted), ADR-042 (T4B self-authorship; assertion ≠ verification), PC-SPEC-01 v0.3 (Admin-Only Mint Rule); design note IDENTITY-ENTRY-PATH-01; ADR-045 (audit-emission discipline — the correspondence log consumes it); ADR-028 (measurement plane, distinct).

Context. PC-SPEC-01 v0.3 establishes self-authorship (T4B) and the Admin-Only Mint Rule, but does not define how an invited person enters the system — specifically, whether person-facts are authored by the admin at invite or by the person themselves, and what gates operational access. Without a canonical entry path, each onboarding-related brick (invitation, self-maintainer view, promotion) would re-derive the flow, and admin-entered vs self-entered person data would coexist and drift. A scale scenario (a 100-person RTO, not the 3-person test client) makes the stakes concrete: an admin cannot and should not type 100 people's positions, contacts, and compliance facts. The data should be first-party.

Decision. RTOpacks adopts a single universal entry path for every invited person, regardless of destination tier: 1. Admin invites from the access surface (Users / Administration→Team), authoring only login facts (email, display name, membership type → tier). Per the Admin-Only Mint Rule, identity is minted only from Administration. The admin does not author the person's extended-identity facts. 2. The person logs in via the common entry gate (my.rtopacks.dev) — the same door for all tiers. 3. Self-authoring is the gate to entry. Before any other surface opens, the person authors their own pc_persons record. Incompletion blocks entry. (Mechanically, this reuses the administrator edit path from PERSON-RECORD-EDIT-PATH-01, pointed at the person's own record.) 4. The authored record terminates in a signed declaration — the person attests the record is true and correct. The declaration is filed into the People record as a timestamped, versioned audit artefact. 5. Admin approval opens access. The admin reviews the attested record; on approval, the grant lands (T4A seat consumed, or T4B confirmed). Approval is an administrative gate distinct from credential verification (assertion ≠ verification, ADR-042).

Rationale. - Self-attested data is a stronger compliance asset than admin-entered data. Attestation shifts the integrity burden onto the person and converts data entry into a defensible audit artefact. This is the substantive reason for self-authoring, beyond labour-saving. - The entry gate is a compliance control. Refusing operational access until the compliance record exists and is attested structurally prevents the classic RTO failure of a person becoming operational before their credentials were recorded. The gate enforces the compliance spine. - Single door, single author per fact. One entry gate for all tiers keeps the model coherent; person-facts authored once, by the person, keeps the two-layer model's "two masters, no merge" intact (ADR-040).

Consequences. - The pending-approval state machine (settled). Between self-authoring and grant the person sits in a pending state: they can log in but see only their own record (own-record-only; no operational surface opens). From pending, the admin can approve or return for correction — a first-class returned state. Return emits a reason; the person's record re-unlocks for editing; they resubmit. The loop pending ⇄ returned may run multiple cycles (expected, not exceptional) and every cycle is preserved. Terminal state is approved, at which the grant lands (T4A seat consumed, or T4B confirmed) and a welcome path opens. - Approval is a recorded human co-sign, not a rubber stamp. A real person reads the attested record and the credential evidence and makes an assessment judgement, recorded as a written notation. This is what gives the attestation its evidentiary value (a microcosm of the train-and-assess feedback loop applied to staff onboarding; the return/resubmit cycle itself evidences continuous-improvement / corrective-action at audit). Approval is an administrative co-sign gate, distinct from credential verification (assertion ≠ verification, ADR-042) — approval accepts the attestation as filed; it does not verify the credentials. - The correspondence is an append-only, immutable, linear log tied to the one onboarding action: each entry {author, role (T4/T4B), timestamp, captured IP, message, action (submitted / returned / resubmitted / approved)}. Linear, not threaded. It is the evidence of the co-sign judgement and the corrective-action loop — multiple return cycles are preserved as that evidence, never overwritten. It emits to the single activity stream via the audit-emission discipline (ADR-045), keyed on canonical identity; the People-card whole-of-life audit view and the onboarding screen are both filtered reads of that one stream (the onboarding screen filtered to this action, the People card filtered to this person). One source of truth, viewed through filters — never a separate store that later reconciles. - The signed declaration is a point-in-time snapshot, not a live read. What the person attested to is frozen at attestation — a later edit to the record must not silently rewrite what was signed. (Resolved at Brick 3c, 2026-06-15: the snapshot is a dedicated versioned artefact, not an activity entry — table pc_attestations in workspace-db, append-only and immutable, one row per attestation with a monotonic version per person, carrying the full attested field-set as a frozen JSON snapshot plus the captured attestation IP. The activity stream carries only a lightweight pointer ({attestation_id, version}); record content never enters the stream. The attestation is the creation event of the person's entry, so it single-homes with the person in the workspace plane — the audit stream logs that it happened; pc_attestations holds what was attested.) - IP capture on attestation is a deliberate decision. Capturing and immutably retaining the attestation IP strengthens the evidentiary weight ("signed from this address at this time"). This is a conscious choice, made with awareness that IP is personal data carrying a privacy dimension across the AU (Privacy Act) and US/Delaware operating contexts — recorded as deliberate, not an unremarked default. (Not legal advice; the privacy posture is owned consciously, to be reviewed as part of any future privacy review.) - Requirement profiles: a seam, not an engine. Brick 3 builds against one requirement profile — the mandatory floor is the existing pc_persons NOT NULL columns (full_legal_name, role, employment_type); everything else is optional and may be supplied later. The entry gate reads "is the mandatory floor present and attested," not "is every field full." The gate is built so its required-set is a parameter it reads, not a constant it hardcodes — so per-type profiles (a new-trainer needing more than a course-creator, an external auditor differing again) can arrive later as data without a rewrite. The profiles engine is deliberately not built; only the seam exists. - Re-attestation on material change (deferred, principle committed). When a material fact changes post-approval, re-attestation is required (principle from this ADR's original consequences). Scope deferred; flagged as interacting with the state machine above (a material post-approval change may re-enter pending — undecided, Phase 2). - Brick 1 (person-record edit path) remains the shared mechanism for both admin editing and first-run self-authoring. - Brick 3 (invitation) implements the full path, not merely login minting. - tier_grants CHECK includes T4B — substrate prerequisite, done (Brick 2.5 / TIER-CHECK-T4B-WIDEN-01, dev+prod, 1b9388e8).

Alternatives considered. - Admin authors person-facts at invite. Rejected: does not scale, produces weaker (non-attested) records, and creates a second author for person-facts that drifts against People. - No entry gate (access first, profile later). Rejected: permits un-attested operational access — the exact audit failure the gate exists to prevent.

See design note IDENTITY-ENTRY-PATH-01 (the model this ADR canonises), ADR-042 (T4B / assertion ≠ verification), PC-SPEC-01 v0.3 (Admin-Only Mint Rule). Execution: informs People Phase 1.5 Bricks 3–5; not built here.


ADR-045 — The unified audit-emission discipline

Status: Accepted Date: 2026-06-14 Relates to: ADR-028 (Telemetry MANDARIN category / Analytics Engine — the other plane), ADR-029 (the activity stream shape this builds on), ADR-024/ADR-033 (canonical identity keys), ADR-026 (application-layer integrity). Consumed by ADR-044 (entry-path correspondence is its first heavy emitter).

Context. ADR-029 established a single uniform activity stream as the audit substrate, chosen over per-event-kind tables precisely so cross-machine "who did what when" is one query, not a scatter to reconcile later. As the platform grows past its first emitters (onboarding correspondence in People Phase 1.5 is the first rich one; Apps, Studio, Record, InstaLearn follow), a decision is needed before multiple surfaces hand-roll their own writes: how every surface emits, so the contract stays uniform and the audit trail becomes a property of the platform rather than a feature re-built per module. Separately, the temptation exists to either (a) build a heavyweight event-processing engine prematurely, or (b) derive product-usage analytics from the audit stream — both are rejected here.

Decision.

1. One record wall. All compliance/security records of human acts land in the single activity stream (ADR-029), in D1 (rtopacks-audit-* per ADR-029, placement of any given emitter's events confirmable at that emitter's build-time). This is the durable, immutable, per-subject record plane. It is distinct from the measurement plane (§5).

2. One emission contract. Every emitted event conforms to the canonical shape (ADR-029: id, timestamp, source, client_id, actor, action, detail), with these load-bearing requirements stated as platform-wide anchors: - actor is a canonical identity key (a bare RFC 4122 UUID for users per ADR-024, cli_… where a client is the actor, system:<worker-name> for system actors per ADR-029) — never an email, display name, or raw string. This is the anchor that makes the per-person whole-of-life view a filtered read rather than a future re-key of live data. - Append-only and immutable. No event is edited or deleted once written (GONE-IS-GONE applies in the inverse — nothing leaves, nothing changes). An editable audit log has no evidentiary value. - Order is determined by the event's captured timestamp, not by write-arrival order. Stated now while writes are synchronous, so the future async swap (§4) introduces no ordering surprise.

3. One emission helper. Every surface emits through a single shared write helper — not a hand-rolled INSERT. The helper takes a typed event and writes it to the activity stream in canonical shape, enforcing the §2 contract (canonical-key actor, immutability, timestamp-ordering, fail-closed on write failure). It is a librarian, not an engine: it does not transform, route, enrich, or reason. Its entire value is contract enforcement at the write boundary and being the single seam behind which the implementation can change. The audit trail is thereby inherited by every surface that emits through the helper — audit is a platform property, told once, not re-built per tile.

4. The heavyweight event-processing engine is explicitly forbidden at this stage. No queue, no async workers, no fan-out routing, no enrichment pipeline. At pre-revenue / low-volume there is no event volume to need async and no multi-sink reality to need routing — such a system would be complexity in search of a home. When real volume or genuine multi-sink/real-time-alerting need appears, the engine arrives behind the helper (§3): the helper's implementation changes from a synchronous write to a queue-and-worker, and no calling surface changes, because every tile already calls one helper. The §2 timestamp-ordering anchor is what makes that swap clean. This ADR records the forbiddance so a future session does not over-build it absent the triggering need.

5. The audit plane is NOT the product-analytics source. Product-usage measurement — which tiles/apps get used, session shape, engagement, spend concentration ("heatmap") — belongs to the measurement plane: Cloudflare Analytics Engine (ADR-028's Telemetry category) and Cloudflare's own request-level traffic data, which is more granular than internal logging could reconstruct. Deriving product analytics by querying the activity stream is rejected: it would bloat the evidence store with analytics-shaped reads and risk warping compliance records with analytics fields. The two walls — record (durable, immutable, per-person, compliance/security) and measurement (aggregate, lossy-tolerant, retention-bounded, per-product) — stay separate permanently, because proving a specific person's act and knowing aggregate product usage are different stakes. Parked, not built: lightweight fire-and-forget telemetry emits MAY later be added at the helper's call sites (the natural seam), but no usage-telemetry is built now — a heatmap of zero users is zero.

Consequences. - ADR-044's onboarding correspondence is the first heavy emitter and consumes this discipline (emits via the helper, to the one wall, under the §2 contract). - Future module surfaces (Apps, Studio, Record, InstaLearn) inherit audit by emitting through the helper; no per-module audit build. - The grey-zone event class (security-flavoured events — failed login, session revoked, passkey added — that an operator might want as telemetry and an admin as audit) is acknowledged: neither plane's schema may assume exclusive ownership of such events, so either can claim them when their classification is decided. Not resolved here; named so a future brief does not foreclose it.

Alternatives considered. - Per-module / per-event-kind logs. Rejected (ADR-029 already did): scatters the trail, forces later UNION/reconciliation. - Heavyweight engine now. Rejected (§4): premature; arrives behind the helper seam when a real need appears. - One unified logging plane for both audit and observability. Rejected (§5): opposite mutability/retention/audience requirements; conflating them risks leaking ops internals to customers (OPS SURFACE RULE) and corrupting evidence integrity.

See ADR-028 (measurement plane), ADR-029 (the stream shape), ADR-024/ADR-033 (identity keys), ADR-044 (first consumer). Execution: the helper is built when its first consumer (Brick 3) needs to emit, built minimal; this ADR is the discipline, not a build instruction.


ADR-046 — The outbound-correspondence channel and touchpoint registry

Status: Accepted Date: 2026-06-14 Relates to: ADR-045 (audit-emission — records that a message was sent; this ADR owns the sending and content — distinct planes), ADR-044 (the entry path is this channel's first multi-message consumer), client-spine.md (interface-as-revelation; no-concealed-second-purpose). Explicitly distinct from SMS Connect (a student-management-system integration) and Marketer (the external/marketplace view of an RTO) — neither is the transactional-communication layer; this ADR does not touch them.

Context. RTO process is substantially correspondence-driven — onboarding, follow-ups, reminders, confirmations, lifecycle notifications all travel as email/SMS. The platform already sends outbound mail today (the magic-link login email is the known first instance) but has no operator-facing acknowledgement that it does — the copy is hard-coded in the sending path and invisible in admin. As the platform grows (the entry path alone adds invite / return-for-correction / welcome messages), this latent pattern compounds: without a discipline, each new touchpoint hard-codes its own copy at its own call site, and the day a customisation surface is wanted, those scattered strings must be hunted across every worker — the avoidable "where do we rewrite this now" cost. Separately, organisations legitimately want their own voice in these messages (a TAFE's People & Culture team wants its tone, its equal-opportunity statement, its branded framing), while the system must own the mechanism (the trigger, the correct recipient, the working link).

Decision.

1. Outbound correspondence is a system-mediated channel. System owns the mechanism (trigger, recipient resolution, link integrity, delivery); the RTO owns the voice (the message copy). Same author-separation as the platform's other seams: the system guarantees the act, the organisation customises the expression.

2. Messages send through one seam, not inline at each call site. Every system-outbound message (email/SMS) emits through a single shared sending path carrying default boilerplate, so the platform works out-of-the-box with no prefill required. No surface hand-rolls a send with hard-coded copy. This is the seam behind which per-org customisation later arrives without touching any call site (the same librarian-not-engine pattern as ADR-045's emission helper).

3. A touchpoint registry makes the channel legible — and is the forcing function. A sub-page under Administration (sibling to Branding) lists every outbound touchpoint: its trigger, its recipient class, and its status (boilerplate written / pending / customisable-later). The rule is every touchpoint lives on this list, including pre-existing ones — the magic-link login email is the first (retroactive) entry, and the registry's establishing act includes an audit of existing send sites to surface any others not yet known. The registry is the discipline made visible (interface-as-revelation applied to the platform's own comms): a hidden hard-coded send cannot persist, because "if it sends, it's on the list." Day one the registry is a read-only inventory, openly marked work-in-progress and possibly sparse; it grows as touchpoints are built.

4. The editor is the registry's evolution — deliberately not built now. The registry sub-page is the eventual home of a per-org correspondence editor (custom copy, tone, the organisation's own framing, leaning on the existing Branding capability for logo/identity). That editor — and any per-org template store — is not built now: the platform doesn't yet know its full touchpoint set or what each must say, so encoding templates now encodes a guess. The seam (§2) and the registry (§3) exist; the editor arrives behind them when the touchpoint set is real and a customer's needs are known. Refuse the editor; keep the seam and the list.

5. Distinct from the audit plane (ADR-045). The audit stream records that a message was sent (an event: actor, action, timestamp). This channel does the sending and owns the content. They touch — a send emits an audit event — but message bodies live in the correspondence channel, never in the evidence store. Keep them separate or compliance records bloat with message copy.

Consequences. - The registry surfaces as part of Brick 3 (People Phase 1.5), populated by Brick 3's real touchpoints (invite / return / welcome) plus the retroactive magic-link entry — not built standalone ahead of having touchpoints to list. Brick 3 routes its sends through the §2 seam and registers them, rather than hard-coding copy. - Future touchpoints across any module inherit the discipline: send through the seam, register on the list. - The per-org editor is a named future capability with a known home (the registry page), not a pending build.

Alternatives considered. - Build the correspondence editor now. Rejected: template-management before the touchpoint set is known is complexity in search of a home; encodes a guess about messages that don't exist yet. - Leave correspondence inline per surface (status quo). Rejected: scatters copy, hides what the system sends from its own operator, and forces a multi-surface hunt when customisation is eventually wanted. - Treat outbound comms as part of the audit/event layer. Rejected (§5): different concern (delivery+content vs record-of-act); merging puts message bodies in the evidence store.

See ADR-045 (record-of-send, distinct plane), ADR-044 (first multi-message consumer), client-spine.md (interface-as-revelation). Execution: the seam + registry establish as part of Brick 3; the editor is later.


ADR-047 — Clause-bound fields: the legislative trace is stored, not generated

Status: Accepted Date: 2026-06-17 Relates to: legislation-to-tile-method.md (the binding this ADR reasons), CLAUSE-BOUND FIELDS standing rule (the review-gate pointer), ADR-048 (the same binding across time), ADR-049 (the anchor's instrument qualification), compliance-reference.md (the existing clause↔module mapping this operationalises at field grain), Knowledge Navigator (the outward consumer).

Context. The product thesis is legislative grounding — "the system that keeps you registered." Every regulated surface is ultimately judged against the Standards. compliance-reference.md already asserts "no inline-hardcoded standards references in module code" and "every field traces," but a principle in a doc is not a mechanism. For the legislative grounding to be real, the link between a field and its clause must be structural data the code carries, not prose a human maintains. Two consumers want that same link from opposite directions: the user (what does this field answer, and why does it matter in audit?) and the maintainer (the law changed — which fields does that touch?).

Decision.

1. Every field on a regulated surface carries a stored trace — anchor (clause within instrument), obligation (plain-English why), consequence (what fails in audit if absent). The trace is data the field carries, like its name and type.

2. Stored, never generated. The trace is authored as fact, not produced by an LLM at request time. In audit, a fixed checkable claim anchored to a clause is evidence; a runtime explanation is chatter that can drift, soften, or contradict itself between two reads, and one wrong explanation rightly poisons trust in all of them. The system shows its working, and the working is the same every time and traceable to the law. This is the posture auditors reward and compliance software almost never holds.

3. Authored in the same act as the field, never backfilled. The trace authored when the architect holds the clause, intent, field, and consequence in mind at once is more truthful than any reconstructed later, regardless of effort — a later author infers three of the four from the one that survived (the field name). Deferring produces a worse artefact, not just a costlier one. A field without its trace is incomplete and does not pass.

4. One source, two consumers. Knowledge Navigator reads the trace outward (tap a field → named clause + plain-English why). The compliance upgrade path reads it inward (law version bumps → anchors surface the touched fields). Knowledge Navigator is not a layer applied afterward; the trace is its content. Building the tile and building Knowledge Navigator are the same act.

Consequences. - Every field on a regulated surface costs more to author (clause + why + consequence before it is done). This is the right tax for a product whose entire value is the grounding; pre-revenue is the cheapest it will ever be to pay. - Knowledge Navigator content is a byproduct of correct field authorship, not separate work. - The upgrade path becomes mechanical: the law diff lights up the anchored fields.

Alternatives considered. - Generate explanations at runtime from the clause + field. Rejected (§2): drift and non-determinism make it chatter, not evidence; fatal in an audit context. - Maintain the trace as prose in compliance-reference.md, hand-maintained. Rejected (§1): a principle is not a mechanism; hand-maintained prose rots the first time someone is in a hurry, and the upgrade path silently stops working. - Backfill Knowledge Navigator copy in a later pass. Rejected (§3): produces hollow, less-truthful traces; retouches the same surface twice.

See legislation-to-tile-method.md for the binding in full.


ADR-048 — No orphan grains: the audit ledger is a read over deliberately-chosen discharging events

Status: Accepted Date: 2026-06-17 Relates to: ADR-047 (the same binding on the schema axis; this is the time axis), ADR-045 (the unified audit-emission discipline — the seam discharging events emit through), ADR-029 (the audit substrate as a uniform activity stream), NO ORPHAN GRAINS standing rule, legislation-to-tile-method.md.

Context. CLAUSE-BOUND FIELDS anchors fields (structural — true by design). The stronger claim is that nothing enters a regulated surface unanchored: actions and artefacts, not just fields, must trace to the obligation they discharge. The motivating vision is the audit ledger — an origin-to-end replay that establishes the veracity of an RTO's documentation before an auditor reads the content. The audit substrate for this already exists (ADR-045 emission discipline, ADR-029 uniform stream; the audit-db is provisioned). The risk is over-capture: anchoring every mutation drowns the ledger in noise, and a noisy ledger is worse than none — an auditor distrusts ten thousand meaningless lines.

Decision.

1. Nothing enters a regulated surface unanchored. Every field, action, and artefact carries or inherits a trace to the obligation it discharges. A thing that cannot be matched to a clause has no place in the system.

2. Events reference fields; fields carry clauses. Never copy the clause onto the event. One anchor per field (a few hundred), inherited by any number of events (millions, free). Copying clauses onto events creates stale historical references the moment the law changes and destroys the provenance the ledger exists to provide. An event records who-did-what-when and which field; the clause is reached through the field, as-versioned.

3. Discharging events are chosen deliberately, per tile, at the modelling move. Which events discharge an obligation is itself legislative reasoning, and it is small — a handful per tile. Entering a credential discharges an obligation; toggling a panel does not. The discharging-event set falls out of the tile's obligations checklist (the checklist says what must be demonstrated; the events are how each is demonstrated over time) — never from "log everything."

4. The ledger is a report writer. The system is the audit; the ledger is a read over captured events, built when the substrate has enough to say — not early. The only thing that must be certain is that the data is captured when the report writer is called.

Consequences. - The expensive thing (clause maintenance) stays small and the cheap thing (events) stays cheap; the model scales to millions of events without multiplying maintenance. - Each regulated tile's modelling move must name its discharging events explicitly (People's first set is named in its obligations checklist). - The rendered ledger/report is named future, not built now.

Alternatives considered. - Anchor every mutation as a discharging event. Rejected (§3): the rice-warehouse foot-gun; noise destroys the ledger's value faster than silence. - Store the clause string on each event. Rejected (§2): stale historical references on law change; provenance liability. - Build the audit-ledger report now. Rejected (§4): polishing a window with no house behind it; build the capture, the report is a read.

See legislation-to-tile-method.md (NO ORPHAN GRAINS) for the binding in full.


ADR-049 — Instrument-qualified anchors; the obligation layer and concordance are named-future

Status: Accepted Date: 2026-06-17 Relates to: ADR-047 (the anchor this qualifies), the deferred-work register in legislation-to-tile-method.md, INSTRUMENT-QUALIFIED ANCHORS standing rule, the held-instruments-as-objects future work, compliance-reference.md (the four-layer regulatory stack).

Context. A clause anchor is meaningless without its instrument: "3.2" under the 2015 Standards is not "3.2" under the 2025 Standards (F2025L00354). Legislation in this sector changes on long cycles, and across changes the intent of an obligation often persists while its clause number, wording, and structure shift (e.g. the introduction of digital/online delivery forced restructure). This was demonstrated live while building the People obligations checklist: the F2025L00354 instrument and the ASQA QA3 practice guide — same regulator, same year — already number industry currency differently (instrument: 3.2; guide: its own 3.3 box). If anchors are written as bare clause numbers now, the corpus cannot be honestly recovered to which law it meant.

Correction annotation (2026-07-07, STANDARDS-CORPUS-REBUILD-01 / STANDARDS-ANCHOR-BLAST-RADIUS-01). The factual claim in the example above is inverted: the authorised F2025L00354 puts industry currency at 3.3(a)(ii) (industry competency at 3.3(a)(i)), for all persons delivering — the ASQA QA3 practice guide's "3.3 box" was correct all along; 3.2 is credentials/under-direction/T&A-CPD only. The "instrument: 3.2" reading was drawn from a held law-corpus reproduction (standards-outcome.md, pre-rebuild) that carried policy-draft wording for Part 3 under the instrument's identity; the defect was found 2026-07-06 and the corpus rebuilt verbatim from the Federal Register's authorised downloads and gate-verified. The original text is left standing as the historical record. This ADR's thesis is vindicated, not weakened: the drift was recoverable because anchors carry their instrument — a bare "3.2" could not have been caught. The correct concordance for this example is industry currency → 3.3(a)(ii) within F2025L00354.

Decision.

1. Anchors are instrument-qualified, always. Clause references carry their instrument ("3.2(a) within F2025L00354"), never a bare clause number. The instrument identity is part of the anchor. This is the one foundation slot that sets in the pour — it costs a slightly longer string now and cannot be retrofitted later.

2. The instrument is the authority; Layer-3 commentary (practice guides) is not a competing clause authority. Where a practice guide packages obligations under different numbers than the instrument, anchors follow the instrument; the guide is used for example activities, known risks, and self-assurance questions.

3. The three-layer model — instrument / obligation / clause — is named, not built now. What persists across instruments is the obligation (stable concept); a clause is the obligation's expression in a specific instrument (historically true forever); a field anchors to a clause. The obligation layer is buildable later precisely because §1 puts the instrument on every anchor. Do not build it now.

4. Concordance is named, built when a transition occurs. When the law transitions, historical anchors are never mutated (a field built against the 2017 instrument was built against the 2017 instrument — a fact about history that stays true forever). A concordance records how each obligation carried forward; current governance is derived forward through it. Authority is hybrid: regulator-published mappings are authority; RTOpacks-authored mappings are marked as interpretation, dated and attributed, never presented as regulatory fact. The concordance is itself the migration audit trail.

Consequences. - Every anchor, from the first field, carries its instrument. Enforced by the INSTRUMENT-QUALIFIED ANCHORS standing rule. - The obligation layer, concordance, and held-instruments-as-objects are on the method's deferred-work register: named, deliberately not built, buildable because of §1. - Concordance provenance discipline (regulator = authority, ours = marked interpretation) is settled now so it is in place when first needed.

Alternatives considered. - Anchor to bare clause numbers, add instrument later. Rejected (§1): the one thing that genuinely cannot be retrofitted; later recovery is guesswork. - Follow the practice guide's clause numbering. Rejected (§2): Layer-3 commentary is not the law; the instrument is the authority. - Build the obligation layer / concordance now. Rejected (§3, §4): encodes a model before a transition exists to model; over-engineering against a future that hasn't arrived.

See legislation-to-tile-method.md deferred-work register for the named-future items.


ADR-050 — Shared qualifications ingestion data model

Status: Accepted Date: 2026-06-18 Relates to: PEOPLE-ONBOARDING-PIPELINE-MAP-01 (the pipeline this ingestion serves), T4B-ONBOARDING-SURFACE-01 (the shared "Your qualifications" ingestion step), people-02-spec (§5 Credential, §6 / §6.2 vocational competency + equivalence), ADR-026 (pc_attestations free-JSON snapshot), ADR-040 (person↔auth join), ADR-051 (the integrity-state model; referenced below as "ADR #2"). Substrate-verified at draft time (2026-06-18) against live rto-workspace-db (81e2919a-…): the table shapes below are read from sqlite_master, not memory. This verification changed the question (see Findings).

The decision needed. The T4B design has one shared "Your qualifications" ingestion step (enter USI / TGA-lookup once) that populates a store feeding two views: Credential (qual-level) and Vocational competency (unit-level). The ADR must settle: when one ingestion act enters a qualification and its units, what rows are written, how are they linked, and where does verification (integrity) live — without duplication and without drift.

Findings from the substrate (these change the imagined problem). Read from the live tables, not assumed:

  1. pc_unit_competencies already models the quals-vs-standalone distinction. It has competency_basis (the discriminator) + basis_qual_or_unit_code + basis_qual_or_unit_title + basis_issuing_rto + basis_date_awarded. So a unit competency already records whether it derives from a qualification or stands alone, and points at its basis. The standalone-unit case we flagged as a design concern is already a first-class substrate citizen. No schema invention needed for it.

  2. pc_credentials and pc_unit_competencies are currently independent children of pc_persons. Both FK to person_id; neither references the other. A qual in pc_credentials and its units in pc_unit_competencies are associated today only by (a) same person and (b) a matching qual code string (pc_credentials.qualification_codepc_unit_competencies.basis_qual_or_unit_code). There is no structural link between a credential row and the unit rows it produced. This is the real open question.

  3. Verification is stored per-table, independently. pc_credentials: verification_method / verification_date / verified_by. pc_unit_competencies: last_verified_date / last_verified_by. So a single USI act that verifies a qual and its units writes verification into two places — drift risk (the integrity-state ADR, #2, must resolve this; flagged here because ingestion is where the drift originates).

  4. pc_attestations.snapshot is free JSON, no FK (ADR-026). The snapshot photographs whatever field-set it's given; it does not enforce structure. So the shared-ingestion structure is decided at the live-table layer; the snapshot just freezes the result.

The proposal.

D1 — One ingestion act, linked rows, joined by an explicit key (not string-matching). When the person ingests a qualification (USI auto or TGA-manual), write: - one pc_credentials row (the qualification — the credential-tile view), and - N pc_unit_competencies rows (its units — the competency-tile view), each with competency_basis = 'qualification' and basis_qual_or_unit_code = the qual code.

Proposed: add an explicit FK pc_unit_competencies.source_credential_id → pc_credentials.id (nullable — null for standalone units). Rationale: today the qual↔units link is a fragile string match on qual code; an explicit key makes "these units came from this credential" structural, survives qual-code edits/supersession, and lets the two views reconstruct cleanly from one ingestion act. Standalone units (the White Card case) leave it null and rely on competency_basis = 'standalone' + their own basis_… fields.

Alternative considered: keep string-match join (no new column). Rejected — string joins on codes drift across supersession and rename, exactly the SUBSTRATE-NAME-MATCHES-SHAPE risk; an explicit key is cheap now and ruinous to retrofit.

Strengthened rationale (Tim, 2026-06-18) — the relationship is immutable historical fact, not a reconstructable convenience: the units a person took under a qualification, divided into core and elective, are how the qualification was actually achieved — an immutable truth captured at ingestion. The unit list can never be inferred from the qual code, because the electives are an expansive, person-specific choice: two people holding the same qual code and title can have genuinely different qualifications in shape depending on which electives they took. The register holds the template (the possible shape, the fixed core); only the person's record holds the actual shape (their electives). Storing only "holds qual X" and reconstructing the units later from the register would return the template, silently lose the person's electives, and effectively fabricate their qualification. Therefore the unit↔qual relationship must be a stored, immutable fact, read by every future use across the product, never reconstructed. (Same family as STORE-THE-ATTESTED-FACT — the shape of someone's qualification is an attested historical fact.) This is independent of D4: what they took and its core/elective status is immutable history; the current superseded-status of those codes is live-read. Both true, different things.

D2 — Ingestion writes to the live tables; the tiles are read-views over them. There is no separate "qualifications store" table. The shared store is pc_credentials + pc_unit_competencies (+ pc_equivalence_mappings for asserted-equivalent units). "Your qualifications" ingestion is a write surface; Credential and Vocational competency are read-views filtered from the same rows. (One record, two lenses — consistent with the T4B/T4A pattern and the model's §6 unit-level evaluation.) No duplication because there is one set of rows, viewed two ways — not two stores to keep in sync.

D3 — Verification is recorded once per claim, at the level the verification act covers. To avoid the per-table drift (Finding 3): a USI transcript verifies the qualification — so verification is recorded on the pc_credentials row, and the units that derive from it (via D1's FK) inherit verification status from their source credential rather than carrying their own independent verification. A standalone unit (no source credential) carries its own verification on the pc_unit_competencies row. Net: verification is stored once, at the level the act actually covers; derived units read up to their source. (Detailed integrity-state modelling is ADR #2 — this ADR only fixes where the verification fact is written so ingestion doesn't seed drift.)

Flag for ADR #2: this implies the integrity-state is computed, not just stored — a derived unit's integrity = its source credential's integrity, unless overridden. ADR #2 owns the derivation rules.

D4 — Supersession is read from the TGA mirror, not frozen at ingestion. Superseded quals/units (the mirror holds supersession data) are flagged at display/derivation time by reading the mirror, not by writing a frozen "superseded" flag at ingestion — so a unit superseded after ingestion still surfaces correctly. (Build-the-seam: the ingestion records the code; supersession status is derived against the mirror.)

Consequences.

  • One new column: pc_unit_competencies.source_credential_id (nullable FK). Everything else uses existing fields.
  • The standalone-unit case needs no new structure (substrate already supports it via competency_basis).
  • The build brief for stage-3 ingestion can be written against this: write one credential + N linked units per qual; null-link standalone units; verification on the credential, inherited by derived units.
  • Hands ADR #2 (integrity-state) a clean boundary: ingestion writes the verification fact at the right level; #2 owns deriving the integrity colour-state from it.
  • pc_attestations snapshot unaffected structurally — it freezes whatever the live tables hold at submit.

Open / deferred to other ADRs.

  • Integrity-state derivation rules → ADR #2 (integrity-state model).
  • Snapshot ↔ R2 document versioning → local ADR (stage 7).
  • pc_equivalence_mappings shape for asserted-equivalent units → touched here (it's the third store) but its internal structure is §6.2 / its own pinning at build-Gate-1.

Substrate-verified against rto-workspace-db at draft time — the verification revealed pc_unit_competencies already models quals-vs-standalone and that the qual↔units link is currently string-only, which reshaped the proposal from "where does ingestion live" to "make the qual↔units link explicit and record verification once." Implementation (the migration adding source_credential_id) is a later build brief; this ADR records the decision.


ADR-051 — The integrity-state model: colour is derived from verification facts, never stored

Status: Accepted Date: 2026-06-18 Relates to: ADR-050 (shared qualifications ingestion data model — writes the verification fact at the right level; flagged this ADR as "#2"), T4B-ONBOARDING-SURFACE-01 (the integrity-colour visual language: verified-at-source glows → admin-verified → asserted → needs-proof → not-started hollow, all on a cool palette; two-axis rule), people-02-spec (§5 Credential, §6 vocational competency), ADR-026 (pc_attestations free-JSON snapshot). Substrate-verified at draft time (2026-06-18) against live rto-workspace-db (81e2919a-…): pc_credentials and pc_unit_competencies DDL read from sqlite_master, not memory. Verification fields confirmed present; both verification-grouping reads returned empty (prod ≈ 2 persons, zero attestations fired) — the structural read (DDL) is the signal, the empty data is honest, not a failure.

The decision needed. The T4B design uses colour to mean verification integrity — how strongly a claim is backed by its source. ADR-050 settled where the verification fact is written (on the credential for derived units; on the unit row for standalone units). This ADR settles how the integrity colour-state is derived from those facts — whether it is stored or computed, how a derived unit inherits its source credential's integrity, what happens at the override edge (a unit verified by a different source than its parent), and how integrity stays cleanly separated from the compliance/workflow axis.

Findings from the substrate (read, not assumed).

  1. No integrity enum exists, and none should be added. Neither pc_credentials nor pc_unit_competencies stores an integrity/colour field. The facts integrity is computed from are already present: pc_credentials.verification_method / verification_date / verified_by; pc_unit_competencies.last_verified_date / last_verified_by. Integrity is a reading of these, not a new thing to store.

  2. verification_method is unconstrained free text (TEXT, no CHECK). This is the one finding that adds work. The integrity state maps from verification_method, so the derivation cannot be reliable while that column can hold any string. ADR-051 must define a constrained vocabulary for it; the application (and ideally a CHECK constraint) must honour it. The constraint itself is a migration — a build-brief item, not this ADR's text — but the vocabulary is decided here.

  3. pc_unit_competencies.status already exists, defaulting 'pending'. This is a state field, and the risk is that it bleeds across axes — carrying integrity work it shouldn't. This ADR rules it onto one axis (below).

The proposal.

D1 — Integrity is derived, never stored as a raw enum. The integrity colour-state of any claim is computed at read/render time from the verification facts on the row (and, for derived units, from the source credential). No denormalised integrity column is written. Rationale: a stored enum drifts the instant an underlying fact changes — a verification method is upgraded, a supersession lands, an admin confirms a pasted link — and a denormalised state can silently disagree with its own source fields. Derived means the colour can never lie about the facts beneath it. (Same posture as ADR-047's clause-bound fields and ADR-050 D4's live-read supersession: store the fact, derive the presentation.)

D2 — The integrity ladder is a fixed, ordered vocabulary derived from verification_method. Five states, ordered by confidence:

Integrity state Meaning Derived from
verified-at-source (highest) the issuing authority confirmed it machine-to-machine verification_method = source-grade (e.g. USI M2M auto-pull)
admin-verified a human admin confirmed against a trusted artefact (USI link click-confirmed, PDF transcript sighted) verification_method = admin-grade + verified_by present
asserted the person claimed it; no external backing yet a claim exists, verification_method null/asserted-grade
needs-proof a claim that requires backing to count, and hasn't got it asserted and the claim is one the engine will need to gate on
not-started (hollow) no claim entered no row / empty

The constrained verification_method vocabulary (Finding 2) is what makes the top three states cleanly separable. Decided vocabulary (the write side must emit only these; CHECK constraint in the build migration): usi_m2m, admin_usi_link, admin_pdf, admin_manual, asserted, null (= not yet verified). The first maps to verified-at-source; the three admin_* map to admin-verified; asserted/null map to asserted/needs-proof per D4.

D3 — Derived units inherit their source credential's integrity; own-verification can only raise it, never lower it. Per ADR-050 D3, a unit with a source_credential_id inherits the credential's integrity. This ADR fixes the override edge: when such a unit also carries its own last_verified_by / last_verified_date that would compute a different integrity than the inherited value, the higher of the two wins — own-verification can raise a unit above its parent (a more specific fact is stronger), but the absence or weakness of a unit-level verification can never demote a unit below the integrity its source credential already established.

Rationale: integrity is confidence, and a more specific verification act only ever adds confidence. Letting a missing unit-level fact pull a unit below its verified parent would manufacture doubt the facts don't support — and would be fail-open in the wrong direction (showing strength as weakness is merely annoying; the dangerous failure is the reverse, which this rule forbids by construction). Standalone units (null source_credential_id) have nothing to inherit and compute purely from their own row.

D4 — needs-proof is a function of the claim's role in evaluation, not a stored flag. The split between plain asserted and needs-proof is not written on the row — it is computed by asking whether the engine will need this claim backed to reach a verdict. An asserted claim that nothing depends on sits at asserted; an asserted claim the canDeliver engine must rely on surfaces as needs-proof. This keeps the integrity axis honest about why a thing is amber-going-grey without inventing a stored state. (Deferred detail: the precise engine-dependency signal is the engine's to define — built before verdict-displays, per the settled build sequence. This ADR fixes that needs-proof is derived from that signal, not stored.)

D5 — Two-axis separation is absolute; status is the compliance/workflow axis only. Colour = integrity (this ADR). Icon/shape = compliance status — the lock (blocked/gated) and flag (stale-but-fine) of T4B-ONBOARDING-SURFACE-01. The existing pc_unit_competencies.status field belongs to the compliance/workflow axis and must not carry integrity meaning; integrity is derived separately from the verification fields and the two never collapse into one column. A needs-proof (integrity, colour) item is not automatically blocked (compliance, lock) — they answer different questions and a surface may show a fully-integrity-verified item that is still compliance-stale, or vice versa.

D5a — The integrity palette is cool-only; warm is reserved exclusively for the compliance axis. Because the two axes must never collapse (D5), they must never share a colour temperature. Every integrity state renders on a cool palette — verified-at-source (teal glow), admin-verified (indigo/teal), asserted (slate/dashed), needs-proof (dashed/hollow-cool), not-started (hollow). Amber and red are compliance-axis colours only (going-stale amber flag, blocks-deployment red lock) and never appear on the integrity axis. Rationale: a shared colour is a collapsed axis to the eye, regardless of what the data model separates. asserted rendering as warm amber — as earlier surface-language drafts had it — reads as a compliance warning when it is in fact an integrity reading; the cool/warm split makes the two-axis rule legible at a glance rather than only in the schema. (Surfaced by the v3 prototype, T4B arc, 2026-06-18.)

Flag for the build brief: if the live status writer is found to be doing integrity work, that is a redline corrected in the stage-3 build — caught cheaply then — not a reason to muddy this decision. The architectural commitment is the clean split.

Consequences.

  • No new columns from this ADR. Integrity is derived; the facts already exist.
  • One vocabulary constraint owed to the build migration: the verification_method CHECK (or application-enforced enum) of D2. Without it the derivation is unreliable — so it is a hard prerequisite of the stage-3 build, recorded here as the decision and deferred to the migration as the mechanism.
  • A single derivation function computes integrity for any row (credential or unit), applying inheritance + the upward-only override of D3. Both tiles and the engine read it; it is written once.
  • The engine dependency for needs-proof (D4) is named-future — it lands when the canDeliver engine is built, before verdict-displays. Until then needs-proof collapses to asserted harmlessly (fail-toward-showing-work-needed).
  • Hands the stage-3 collection-flow build a clean integrity contract: write the verification fact (ADR-050), and the colour falls out of this derivation — no integrity state to maintain by hand.

Open / deferred.

  • verification_method CHECK constraint migration → stage-3 build brief (mechanism for D2).
  • The engine-dependency signal for needs-proofcanDeliver engine ADR/spec (D4's input).
  • pc_equivalence_mappings integrity — asserted-equivalent units: their integrity is asserted at best by definition (equivalence is a human judgement, never source-verified); full treatment with the equivalence build (§6.2).

Substrate-verified against rto-workspace-db at draft time — confirmed no integrity enum exists (integrity is correctly derivable from present fields) and that verification_method is unconstrained free text, which made the verification_method vocabulary a load-bearing part of this decision rather than an afterthought. This ADR records the decision; the CHECK-constraint migration and the engine-dependency signal are deferred to their respective build briefs.


ADR-052 — The identity-record data model: contact facts live on the compliance record, not borrowed from the login

Status: Accepted Date: 2026-06-18 Relates to: ADR-040 (person-data ownership seams; the pc_persons.user_id → users.id join), ADR-041 (no person-core database), people-02-spec §10.1 (the two-layer identity model), §10.5 (Admin-Only Mint Rule), §10.6 (role multi-hat), §10.7 (position), T4B-ONBOARDING-SURFACE-01 (the Identity tile), the HARD SEPARATION rule. Substrate-verified at draft time (2026-06-18) against live prod: pc_persons (workspace-db 81e2919a-…) and users (identity-db 8f0ba518-…) DDL read from sqlite_master; the full pc_* table list enumerated. The field-presence findings below are read, not assumed.

The decision needed. The T4B Identity surface must capture a complete compliance identity record. A live read established that most of the personal/contact block that such a record legitimately holds has nowhere to live today — and that the one contact fact that does exist (email) lives only on the login (users, Layer 1), reachable through a nullable join. This ADR settles the identity-record field set, decides which layer contact facts belong to, names the new substrate the build must add, and fixes the Layer-1/Layer-2 boundary as a hard edge so future migrations don't erode it.

Findings from the substrate (read, not assumed).

  1. pc_persons holds identity/engagement/suitability, but no personal-contact block. Present: full_legal_name, preferred_name, employment_type, role, start_date/end_date, contract_type, state_of_operation, usi, the WWCC block, police_check_*, fit_proper_declaration_date, profile_status/stub_source, and the nullable user_id. Absent entirely: date of birth, residential address, phone, position, and any next-of-kin / emergency-contact record.

  2. Email exists only on users.email (Layer 1, identity-db), and the join is nullable. pc_persons.user_id → users.id is admin-asserted and nullable by design (§10.1) — a compliance record can exist with no login. Therefore a pc_persons row with no linked login has no email and no way to be reached. The login email is an authentication credential; it is not, and cannot be relied on as, the person's contact fact.

  3. Two surfaces already render fields the substrate doesn't store. The PersonDrawer shows phone and position; neither is backed (phone unstored entirely; position is the spec-only §10.7 addition, not migrated). Presentation is ahead of storage in two places — a thing this ADR's build closes, not widens.

The proposal.

D1 — Contact and personal facts live on the compliance record (pc_persons, Layer 2), never borrowed from the login (users, Layer 1). Add to pc_persons: date_of_birth, residential_address, phone, and a contact email as a fact about the person — distinct from users.email, which remains the authentication credential. Rationale: §10.1 already established this principle for legal name ("legal name lives once, on pc_persons"). The same logic governs all identity facts: a credential and a fact are different things with different governance. A nullable login-join cannot be where a person's reachable contact lives — the nudge email, the invite, the correspondence log all need to reach a person who may have no login at all. The two emails will usually carry the same string; they are not the same field. (Fail-closed posture: the record owns its own contact truth; it does not depend on a login existing.)

D2 — Next-of-kin is its own child table, pc_next_of_kin, not columns on pc_persons. It is a multi-attribute, potentially-more-than-one record (name, relationship, phone) — modelled like the existing pc_supervision / pc_cpd children, consistent with how the substrate already does one-to-many. Captures the duty-of-care obligation Jimmy's auditor review raised. (Whether one or several contacts are permitted is a build-detail; the table shape allows more than one by construction.)

D3 — position is migrated to pc_persons as the spec-only §10.7 field made real. Free-text job title, product-layer, non-gating, no clause trace, does not affect any canDeliver determination. Closes the presentation-ahead-of-storage gap (Finding 3). Distinct from role (the multi-hat presentation lens, §10.6) which is a separate, possibly-structural change owned elsewhere and not decided here.

D4 — The Layer-1/Layer-2 boundary is a hard edge this ADR does not cross. Identity-record facts — name, contact, personal, suitability — live on Layer 2 (pc_persons + children, workspace-db). Authentication, authorisation, tier grants, seat consumption, login-email, session, deactivation live on Layer 1 (users, tier_grants, identity-db), governed from Administration → Team under the Admin-Only Mint Rule (§10.5). The two layers reconcile through exactly one documented point — pc_persons.user_id → users.id (ADR-040) — and nothing else. This ADR migrates Layer-2 substrate only. No login, tier, seat, or auth field is denormalised onto pc_persons for convenience; the boundary that protects HARD SEPARATION and the two-layer model is not to be eroded one well-meaning migration at a time (ADR-041: no person-core database). Corollary (SSO): when a client's logins come from an external IdP (Azure AD / Okta), SSO plugs into Layer 1 and the Layer-2 compliance record is untouched — which holds precisely because contact-as-a-fact (D1) is kept separate from email-as-credential.

D5 — Suitability-bearing fields are NOT settled in this pass. The descriptive/contact block (D1–D3) is decided wide because these are low-risk identity facts an RTO's record plainly holds. Anything touching suitability — work-rights / residency, expanded fit-and-proper scope, the items Jimmy's auditor review raised beyond duty-of-care — carries legislative weight and gets its own legislation-to-tile grounding (trace to instrument and clause), not a column waved in under cover of this migration. Rationale: the convenience of a single migration must not drag ungrounded suitability fields into the model. Trivial to add a grounded field later; hard to justify a present-but-empty or ungrounded field in hindsight.

D6 — Model is scoped wide; surface is scoped narrow. This ADR settles the data model for the complete identity record (pay the structural cost once, pre-revenue — "build the building"). What renders on the Identity surface, in what order, for which person-shape (admin / standalone-unit holder / empty shell / trainer), with what language, is a presentation decision fitted per-build and per-tile later ("fit the interiors"). A field existing in the model does not oblige it onto the first screen. The surface stays honest: it shows what it stores, no presentation ahead of storage (closing Finding 3, not repeating it).

Consequences.

  • One coherent migration brief (not this ADR's text — deferred, as 050/051 deferred theirs): add to pc_personsdate_of_birth, residential_address, phone, email (contact-fact); create pc_next_of_kin (person_id FK, name, relationship, phone); migrate position. Single shape, single migration — avoids the compounding-migration / drift cost of adding these piecemeal.
  • The contact-email duplication is deliberate, not redundantusers.email (credential) and pc_persons.email (fact) coexist; the build should make the usual-case (same address) ergonomic without collapsing them into one field.
  • Boundary is load-bearing for SSO and HARD SEPARATION — D4 is the edge that lets Layer 1 be replaced by an external IdP without touching the compliance record.
  • Suitability grounding owed (D5) before any suitability field beyond what already exists is added — named-future, its own grounding pass.
  • Hands the Identity surface a complete, honest record to capture — the precondition the prototype work was missing: form follows a settled model, not the reverse.

Open / deferred.

  • The migration DDL (D1–D3 columns + pc_next_of_kin) → its own migration brief; MIGRATION-COMPLETION-DISCIPLINE applies (retirement scope, if any, named in the brief).
  • role multi-hat (§10.6) — a separate, possibly-structural change; explicitly not decided here.
  • Suitability field grounding (D5) — legislation-to-tile, named-future.
  • Whether contact-email auto-syncs/suggests from users.email at the usual-case — build ergonomics, not a model decision.

Substrate-verified against prod at draft time — confirmed five of six named identity fields (DOB, address, phone, next-of-kin, position) have no storage today and email lives only on the Layer-1 login, which is what forced the contact-on-Layer-2 decision (D1) rather than leaving it implicit. This ADR records the model decision and the layer boundary; the migration DDL and any suitability-field grounding are deferred to their own briefs.


ADR-053 — Two axes of the submission record: snapshot-versioning is built, section-approval is new substrate

Status: Accepted Date: 2026-06-18 Relates to: ADR-044 (the /entry self-authoring gate; captured_ip at attestation), ADR-026 (app-layer person_id ref, no FK), ADR-024 (canonical usr_…), ADR-052 (the identity-record data model the snapshot freezes), T4B-ONBOARDING-SURFACE-01 (the submit/lock and per-section review surfaces), the store/submit/lock brick this ADR precedes. Substrate-verified at draft time (2026-06-18) against live prod: pc_attestations DDL read from sqlite_master in workspace-db 81e2919a-6587-40a1-b749-0a65103d95f0 (NOT identity-db — see Finding 0); row count confirmed 0. The findings below are read, not assumed.

The decision needed. The store/submit/lock brick wires the submission act onto the existing pc_attestations snapshot table, and the per-section review surface sits on top of it. Two axes run through that machinery and must not be conflated: submission-version (each submit freezes an immutable, versioned snapshot of the whole record) and section-approval (a reviewer approves a part of a submission, and an approved part stays locked across the person's later resubmissions unless that part's own content changes). A live read settled which of these the substrate already carries and which is genuinely new. This ADR ratifies the version axis, defines the section-approval axis as its own substrate, and fixes the reopen rule so an approval cannot silently survive a content change beneath it.

Findings from the substrate (read, not assumed).

  1. pc_attestations lives in workspace-db (81e2919a-…), not identity-db. The full pc_* family — pc_attestations, pc_persons, pc_next_of_kin, pc_credentials, pc_licences, pc_cpd, pc_unit_competencies, pc_professional_experience, pc_supervision — is enumerated in workspace-db. identity-db holds no pc_* tables. Any prior note placing the snapshot table in identity-db is drift; the brief that writes submissions targets 81e2919a-….

  2. The version axis already exists and is decided by the table. pc_attestations carries version INTEGER NOT NULL — documented as monotonic per person_id (1, 2, 3 across return cycles) — and snapshot TEXT NOT NULL, the frozen JSON of the attested field-set. Submission-versioning is therefore not an open design question; it is built. The snapshot is a whole-record blob per version, not a per-section structure.

  3. There is no section-approval axis anywhere in the substrate. pc_attestations records who attested, when, from what IP, at what version, and the frozen blob — and nothing about a reviewer, an approval, or a part of a submission being signed off. The second axis has no storage today. So this is not a "stop two existing axes conflating inside one table" problem; it is a "build the missing axis without contaminating the built one" problem.

  4. The table has never fired (count 0). No production record constrains the decision. Whatever the section-approval substrate looks like, it lands clean.

The proposal.

D1 — Submission-version is ratified as-built; the snapshot is immutable and whole-record. Each submit writes one new pc_attestations row at the next version for that person_id, freezing the entire attested field-set as snapshot. Rows are append-only: a submission is never mutated or deleted after it is written (editing happens on the live record before submit, per ADR-044's save-vs-submit split — saving is free, submit freezes). The version axis is the immutable spine of the record; section-approval is layered over it and never writes back into it.

D2 — Section-approval is its own table, pc_section_approvals, referencing the version it was approved against. It does not live as columns on pc_attestations (that table is the frozen person-authored truth; a reviewer's act is a different actor's record and must not mutate the attestation). Shape (mirrors the pc_* child convention — app-layer ref, no FK, server-stamped):

pc_section_approvals (
  id            TEXT PRIMARY KEY,    -- minted id
  person_id     TEXT NOT NULL,       -- → pc_persons.id (app-layer ref, ADR-026; no FK)
  org_id        TEXT NOT NULL,       -- denormalised for org-scoped reads, matches pc_persons
  attestation_id TEXT NOT NULL,      -- → pc_attestations.id: the exact version this approval was made against
  approved_version INTEGER NOT NULL, -- the pc_attestations.version this approval read (denormalised for cheap reopen-checks)
  section_key   TEXT NOT NULL,       -- the field-group approved (see D3) — enum-bounded at app layer
  approved_by   TEXT NOT NULL,       -- bare RFC-4122 UUID — the reviewer; never self (see D5). Matches attested_by format (bare UUID), NOT usr_… — the usr_… claim was stale (corrected 2026-06-19)
  approved_at   TEXT NOT NULL,       -- ISO 8601, server-stamped
  captured_ip   TEXT NOT NULL        -- CF-Connecting-IP at approval (ADR-044 parity — reviewer act is evidence too)
)

An approval is a fact pinned to a (person_id, section_key, approved_version): this reviewer approved this field-group as it stood at this version. It is append-only on the same posture as the attestation — an approval is never edited; a change of mind is a new row (or a withdrawal row, a build detail not settled here).

D3 — A "section" is a fixed field-group, not a snapshot sub-tree diff. section_key is a bounded enum of named groups (Identity, Currency, Credentials, Vocational competency, … — the exact set tracks the tile model, settled per-build, not frozen here). Approval locks a named group; reopen is evaluated at group granularity. Rationale: the snapshot is a single whole-record blob (Finding 1) — finer-grained sub-tree diffing would require imposing and maintaining a section→JSON-path mapping the table does not carry, which is structure and complexity bought before review churn proves it is needed. Field-group locking is honest about the current shape; the reopen rule (D4) stays a simple group-level comparison. Sharpening to sub-tree diffing is a named-future change if and only if churn data demands it — it is not foreclosed, just not pre-paid.

D4 — The reopen rule: an approved section stays locked across resubmissions unless that section's own content changed. On a new submission at version N+1, for each section that holds an approval against an earlier version, compare that section's field-group between the approved version's snapshot and the new snapshot: - Group content identical → the approval carries forward; the section stays locked and is not re-presented to the reviewer. (No churn for sections the person didn't touch.) - Group content differs in any field → the approval does not carry forward; the section reopens for review against the new version. The prior approval row is not deleted — it remains true of the version it was made against (append-only, D2); it simply no longer matches the current version, so the section is unapproved-as-of-now.

The two axes never conflate because the reopen test is the only coupling between them, and it is a read: section-approval reads the version axis's snapshots to decide carry-forward; it never writes back into pc_attestations. "Approved-as-of-current-version" is always derived — an approval row plus a content match — never a stored flag on the attestation that could drift out of sync with the content beneath it. (Fail-closed: if the comparison cannot be made — missing snapshot, malformed group — the section is treated as unapproved and reopens. Ambiguity resolves to least privilege, not to a stale lock.)

D5 — A reviewer is never the person, and a section's approval is never self-signed. approved_by must differ from the submission's attested_by. Both are bare RFC-4122 UUIDs (the usr_… format claimed in the original draft was stale — verified against prod 2026-06-19; the comparison is bare-UUID-to-bare-UUID). This is the same self-sign prohibition the equivalence-matrix carries (fail-closed at the surface), now made structural for section-approval: the T4B person who authored and submitted a record cannot approve their own sections. Enforced at the app layer (no FK to lean on), asserted here so the brick builds it in rather than retrofitting it.

Consequences.

  • One migration adds pc_section_approvals to workspace-db 81e2919a-… (both envs, dev=prod, MIGRATION-COMPLETION-DISCIPLINE — retirement scope nil, purely additive). pc_attestations is untouched by this ADR; its shape is ratified, not changed.
  • The store/submit/lock brick builds against a decided shape. Submit writes pc_attestations at next version (built table); the review surface, when it lands, writes pc_section_approvals and derives current-approval-state via the D4 read. The submit brick does not need pc_section_approvals to exist yet — submission-versioning stands alone; the approval table is the review surface's precondition, the brick after.
  • "Approved" is always a derived state, never a stored flag. No approved boolean ever lands on pc_attestations or on a live field — the moment a flag and the content it blesses live in different places, they drift. Derivation is the safeguard, and it is cheap (approved_version is denormalised onto the approval row precisely so the reopen-check is one comparison, no version-walk).
  • Reviewer acts are evidence toocaptured_ip + server-stamped approved_at + canonical approved_by give the approval the same defensibility posture as the attestation (ADR-044 parity). The correspondence/audit record the design note calls for is satisfied by both tables being append-only.
  • Self-sign prohibition is structural, not just surface (D5) — closes a gap the equivalence-matrix only held at presentation.

Open / deferred.

  • The migration DDL (pc_section_approvals) → its own migration brief; additive, retirement scope nil, both envs byte-verified.
  • The section_key enum's exact members → settled per-tile as the surface is built (tracks the tile model); bounded at app layer, not frozen in this ADR.
  • Approval withdrawal / change-of-mind mechanics (a withdrawal row vs. a superseding approval) → build detail, not settled here; the append-only posture (D2) is the only constraint.
  • Sub-tree diff sharpening (D3) → named-future, contingent on observed review churn; not pre-paid.
  • Whether pc_attestations needs a submission-level status (submitted / under-review / returned) distinct from section state → flagged, not decided; may be derived rather than stored on the same "no stored flag" logic (D4 consequence). Resolve when the review surface is specced.

Substrate-verified against prod at draft time — confirmed the version axis is already built into pc_attestations (so this ADR ratifies rather than designs it), confirmed no section-approval substrate exists (so that axis is genuinely new), confirmed the table lives in workspace-db not identity-db (correcting prior drift), and confirmed 0 rows (so the decision lands unconstrained). This ADR settles the two-axis model and the reopen rule; the pc_section_approvals migration DDL is deferred to its own brief.


ADR-054 — Verdict-with-feedback is atomic: a regulated judgement binds the verdict and its reason into one act

Status: Accepted Date: 2026-06-19 Accepted: 2026-06-22 (proven in code via live UI walk; status was set early on 2026-06-19 in Brief B 04d21054 — see lifecycle-miss redline below) Relates to: ADR-053 (the two-axis submission model — this ADR supersedes its pc_section_approvals table with the unified pc_section_reviews, and closes its deferred "approval withdrawal / change-of-mind" open item), ADR-044 (the /entry self-authoring gate; captured_ip as evidence), ADR-046 (the outbound-correspondence channel — the return touchpoint), ADR-048 (no orphan grains — the review trail is a deliberately-chosen discharging-event stream), ADR-026 (app-layer person ref, no FK), the per-section review surface brick this ADR precedes. Substrate context (verified, REVIEW-SURFACE-RECON-01, 2026-06-19): the whole-record approve/return/pending machinery is built and live (onboarding.tsapproveOnboarding, returnOnboarding, getPendingOnboarding; surface at /admin/approvals). Whole-record return already requires a reason (returnOnboarding rejects a reasonless return) — but that reason is audit-only: it lives in the activity detail and the ADR-046 email, persisted nowhere as structured, queryable data. pc_section_approvals (shipped ff7a532f) is live, zero rows, never consumed. No per-section notion exists yet.


The principle this ADR settles.

A regulated judgement against a standard binds the verdict and its reason into a single, atomic act. The negative verdict cannot exist in the record without the feedback that makes it actionable. "No" is not a tick — it is a tick plus the account of what was deficient and what is required to remedy it, and the two are recorded together or not at all.

This is not a local rule for one surface. It is the same act that recurs across the system wherever a person judges evidence against a standard:

  • An assessor judges a candidate's evidence against a unit's requirements — Satisfactory / Not Yet Satisfactory (Competent / Not Yet Competent at unit level). An NYS judgement is incomplete, and non-compliant, without the feedback telling the candidate what was missing and how to close it. An RTO whose assessors record NYS with no feedback is an audit finding waiting to happen.
  • An administrator reviews a trainer's compliance submission against the Standards — approved / returned. A return is incomplete without the reason telling the trainer what was deficient and what to resubmit.

Same act, two altitudes: a judgement against a standard where the negative verdict is meaningless — and unauditable — unless it carries its feedback. The system must make the feedback mandatory and structural, not an optional note an unhurried reviewer can route around with a phone call. Where the trail records "returned → resubmitted" with nothing in between, the record has an orphan: a state change with no account of why (ADR-048). Binding verdict-and-feedback closes that orphan at the point the verdict is made.

This ADR makes the principle structural in its first application — the per-section review of a T4B submission. It is written at the altitude of the principle deliberately, so the assessment-judgement surfaces (Record / assessment, named-future) inherit it rather than re-deriving it.


The decision.

D1 — Verdict-with-feedback is atomic for the negative verdict. A return (the negative verdict on a section) MUST carry a structured reason. The reason is captured at the moment of the return, persisted as first-class data, and the return cannot be recorded without it. A positive verdict (approve) may carry an optional notation but does not require one — the asymmetry is deliberate: the approval's justification is the matched content itself; the return's justification is not derivable from anything and must be stated.

D2 — One unified review table, pc_section_reviews, carrying both outcomes. A review of a section is one kind of act with two outcomes (approve / return), not two different acts. One table — not a pc_section_approvals + pc_section_returns pair — because: - the reviewer history of a section reads as a single ordered stream (approved v2, returned v3 with reason, approved v4), not a stitch across two tables; - the D4-inherited reopen logic and the D5-inherited self-sign check apply identically to both outcomes — splitting tables duplicates the rules; - a return's reason is structured data of the same standing as an approval's evidence — it belongs in the same record of reviewer acts, not in a side-channel.

This table supersedes pc_section_approvals (ADR-053 D2). See Supersession below.

Shape (mirrors the pc_* child convention — app-layer ref, no FK, server-stamped; bare-UUID actor, see D6):

pc_section_reviews (
  id               TEXT PRIMARY KEY,        -- minted id
  person_id        TEXT NOT NULL,           -- → pc_persons.id (app-layer ref, ADR-026; no FK)
  org_id           TEXT NOT NULL,           -- denormalised for org-scoped reads, matches pc_persons
  attestation_id   TEXT NOT NULL,           -- → pc_attestations.id: the exact version reviewed against
  reviewed_version INTEGER NOT NULL,        -- the pc_attestations.version this review read (denormalised for cheap reopen-checks)
  section_key      TEXT NOT NULL,           -- the field-group reviewed (ADR-053 D3 — enum-bounded at app layer)
  verdict          TEXT NOT NULL,           -- 'approved' | 'returned' — app-layer enum-bounded
  reason           TEXT,                    -- the feedback. NOT NULL in effect when verdict='returned' (D1, enforced app-layer); NULL/optional notation when 'approved'
  reviewed_by      TEXT NOT NULL,           -- bare RFC-4122 UUID — the reviewer; never self (D6). Matches attested_by format, NOT usr_… (see ADR-053 redline)
  reviewed_at      TEXT NOT NULL,           -- ISO 8601, server-stamped
  captured_ip      TEXT NOT NULL            -- CF-Connecting-IP at review (ADR-044 parity — reviewer act is evidence too)
)

A review is a fact pinned to (person_id, section_key, reviewed_version, verdict): this reviewer reached this verdict on this field-group as it stood at this version, with this reason. Append-only: a review is never edited; a change of verdict is a new row. (This is how the ADR-053 "withdrawal / change-of-mind" open item resolves — a superseding review row, not a mutation or a delete. The ordered stream per section IS the change-of-mind record.)

D3 — "Section" granularity is inherited unchanged from ADR-053 D3. section_key is the same bounded enum of named field-groups; review locks/reopens at group granularity; sub-tree diffing remains named-future, not pre-paid.

D4 — The reopen rule is inherited from ADR-053 D4, now reading verdict. "Approved-as-of-current-version" is still always derived, never a stored flag: a section is approved-as-of-now iff its latest review row for the current (person_id, section_key) has verdict='approved' AND that row's reviewed_version's snapshot field-group content-matches the current version's. On resubmission at version N+1: group content identical → the approval carries forward (section stays locked, not re-presented); group content differs → the section reopens (the prior approved row stays true of its version, append-only, but no longer matches current, so the section is unapproved-as-of-now). Fail-closed unchanged: unmakeable comparison → treated as unapproved → reopens. A returned verdict never "carries forward" as a lock — a returned section is open by definition until a later approved row matches current content.

D5 — The terminal grant-mint stays whole-record and is gated on section state. Per-section review does not fragment the existing terminal act. approveOnboarding remains the sole grant-minter (mints tier_grants, flips portal_invites.status, runs the seat-cap recheck and admin-authz gate) — untouched. The whole-record Approve becomes enabled only when every required section_key derives as approved-as-of-current-version (D4). Per-section approval is the path to being allowed to press the terminal Approve; it is not a replacement for it. Per-section return fires per section (reopens that group, sends the ADR-046 touchpoint with the reason); the whole-record Approve is the capstone. Whether portal_invites.status also needs an intermediate under-review state distinct from section state — ADR-053's flagged-not-decided open item — is resolved at surface-spec time; default lean is derived, not stored, per the no-stored-flag logic.

D6 — Self-sign prohibition is inherited from ADR-053 D5 and corrected to bare-UUID. reviewed_by MUST differ from the submission's attested_by. Both are bare RFC-4122 UUIDs (verified: pc_persons.user_id and attested_by are bare UUIDs, no usr_ prefix; the usr_… claim in ADR-053 D2/D5 and the pc_attestations DDL comment is stale — see the ADR-053 redline shipping alongside). The check is bare-UUID-to-bare-UUID, enforced app-layer (no FK to lean on). The T4B person who authored and submitted a record cannot approve OR return their own sections.


Supersession of pc_section_approvals.

pc_section_approvals (ADR-053 D2, shipped ff7a532f, live both envs) is retired and replaced by pc_section_reviews. This is honest to flag rather than bury: we are retiring substrate shipped the same day.

It is the cheapest possible moment to do it. The table is zero rows and never consumed (REVIEW-SURFACE-RECON-01 item 6 — rg finds no reader or writer). No data migrates; no code unwires. The retirement is DROP TABLE pc_section_approvals + its two indexes, executed in the same migration that creates pc_section_reviews, with retirement scope explicit per MIGRATION-COMPLETION-DISCIPLINE — not a quiet drop. Had pc_section_reviews been delayed until after the table held rows, this would have been a data migration; doing it now, while the table is empty, it is a clean replace.

Why not keep pc_section_approvals and add a sibling pc_section_returns? Considered and rejected (D2 rationale): it splits one act across two tables for the life of the system, duplicates the reopen and self-sign rules, and fragments the per-section reviewer history. The one-day-old table being clean to retire is precisely why now is the moment to consolidate, not preserve.


Consequences.

  • One migration retires pc_section_approvals and creates pc_section_reviews in workspace-db 81e2919a-… (prod) and 25995e53-… (dev). Retirement scope explicit (the DROP), not nil — distinct from the ADR-053 migration. Both envs byte-verified, dev=prod.
  • The per-section review surface builds against pc_section_reviews — writes a review row per verdict, enforces mandatory reason on return app-layer, derives approval-state via D4, gates the terminal whole-record Approve on full section approval (D5), enforces self-sign (D6).
  • Return feedback is now first-class, queryable, durable — not audit-only. The whole-record return path (returnOnboarding) keeps its existing reason-required behaviour and its ADR-046 touchpoint; the per-section return persists the reason structurally in pc_section_reviews.reason. (Whether whole-record return should also persist structurally, or stay audit-only as today, is a surface-spec call — not forced by this ADR.)
  • "Approved" remains a derived state, never a stored flag (D4) — unchanged from ADR-053; the principle survives the table change intact.
  • Reviewer acts are evidencecaptured_ip + server-stamped reviewed_at + bare-UUID reviewed_by give every review (approve and return alike) the ADR-044 defensibility posture.
  • The assessment-judgement surfaces inherit D1 — when Record/assessment surfaces are built, Satisfactory/NYS judgement carries the same atomic verdict-with-feedback structure; this ADR is the canonical statement they cite, not a per-surface re-derivation.

Open / deferred.

  • The migration DDL (pc_section_reviews create + pc_section_approvals drop) → its own migration brief; retirement scope explicit, both envs byte-verified.
  • The section_key enum members → inherited ADR-053 open item; settled per-tile as the surface is built.
  • Intermediate portal_invites review-status (under-review distinct from section state) → ADR-053's flagged-not-decided item; resolve at surface-spec, default lean derived-not-stored.
  • Whether whole-record return persists its reason structurally (vs the current audit-only) → surface-spec call; not forced here.
  • Sub-tree diff sharpening (D3) → inherited named-future; not pre-paid.

This ADR raises the return-reason from an audit-only side-effect to a structural, mandatory, queryable part of the regulated judgement — and names the principle (verdict-with-feedback is atomic) at the altitude where it governs both compliance review and assessment judgement. It unifies ADR-053's approval table into pc_section_reviews carrying both outcomes, retiring the same-day pc_section_approvals while it is still empty (cheapest moment), and corrects the inherited usr_… actor-format claim to bare-UUID. Accepted 2026-06-19 — PC-SECTION-REVIEW-SURFACE-01 proved it in code: the migration shipped (pc_section_reviews live both envs), commitSectionReviews enforces the atomic verdict-with-reason (mandatory reason on return, app-layer), the self-sign block (reviewed_by ≠ attested_by, bare-UUID), draft-then-commit (the batch is validated then written in one act, never half-formed), and the whole-record approveOnboarding stays the sole minter, gated on every populated section deriving approved-as-of-current (D5).

Proven 2026-06-22 via the live UI return walk on dev (full evidence below). The mechanism is proven in code; the operating surface is not yet presentation-ready — see the engine-vs-surface caveat on ADR-056 (2026-06-22).


ADR-055 — The honest review loop, part two: the resubmission carries its diff

Status: Accepted Date: 2026-06-19 Accepted: 2026-06-22 (proven in code via live UI walk; status was set early on 2026-06-19 in Brief B 04d21054 — see lifecycle-miss redline below) Relates to: ADR-054 (the honest review loop, part one — verdict-with-feedback is atomic; this ADR is its partner: ADR-054 governs the reason going out, this governs the diff coming back; they compose at the review surface but are separable principles), ADR-053 (the two-axis submission model — the snapshot this diff reads, and the section-grain reopen rule D3/D4 this ADR explicitly does not disturb), ADR-052 (the contact-block fields that freeze into the snapshot), ADR-044 (the save-vs-submit split that produces the frozen versions a diff compares).


The principle this ADR settles.

A record that passes back and forth across a review boundary — an author on one side, a reviewer on the other — must carry, truthfully, what changed since the last crossing. When work is returned and resubmitted, the reviewer must not have to re-read the whole record to find what moved; the system shows the change, computed from fact, not from anything the author or reviewer declared.

This is the second half of the honest review loop. ADR-054 (part one) made the negative verdict carry its reason — the reviewer cannot reject without saying why. This ADR makes the resubmission carry its diff — the author cannot resubmit without the system surfacing what actually changed. Together they close the two orphans a naive review loop leaves: a rejection with no stated cause, and a resubmission with no visible delta. Each is separable (a loop could have one without the other), so each is its own ADR; they meet at the surface.

Like ADR-054, this is stated at the altitude of the principle, not the surface, because the same back-and-forth recurs across the system wherever an author submits to a reviewer who returns for revision:

  • trainer ↔ admin on a compliance submission (the first application, the per-section review surface this ADR precedes);
  • candidate ↔ assessor on assessment evidence (Record, named-future — the same act, already noted under ADR-054);
  • any submit → review → return → resubmit loop that follows.

Naming it once means those surfaces inherit a single, consistent diff discipline rather than each re-deriving it (and risking deriving it differently and drifting).


The decision.

D1 — The diff is observed, never declared. What changed is computed by comparing the frozen snapshot of the previously-reviewed version against the new submission. It is never derived from a user action — no "unlock" or "edit" button, no author flag, no "I changed this" checkbox. Rationale: a declaration can be wrong in both directions — an author can signal a change and make none (a false positive that wastes reviewer attention), or a value can move without the declaration (autofill, a re-upload, a fix to a linked field, a future bulk action) and the signal then says "nothing here" while something moved (a false negative the reviewer trusts, which is worse than no signal). An observed snapshot comparison cannot lie in either direction: if a value differs between versions, it shows; how or why it changed is irrelevant. A signal a human relies on must report reality, not intention.

D2 — The diff is display-only. It directs attention; it never drives state. The diff is computed at display time and used solely to highlight, for a human, what to look at: - outbound (to the author, on a returned record): the reviewer's anchored feedback is highlighted on the field-group it concerns — "here is what I need" (this is ADR-054's reason, positioned; ADR-054 owns the reason, this ADR owns its placement-by-diff-mechanism); - inbound (to the reviewer, on a resubmission): the changed fields are highlighted — "here is what the author actually touched" — so re-review is bounded to the real delta.

Same highlighter mechanism, two directions. Critically, the diff is never an input to the lock/reopen state machine. That machine stays section-grain exactly as ADR-053 D3/D4 built it: an approved section reopens iff its field-group content differs between versions — a coarse, section-level test. This ADR adds finer display within a reopened section; it does not make the reopen decision depend on field-level comparison. This is the distinction that keeps ADR-053's parked sub-tree-diff sharpening parked: we diff for attention, not for decisions. The lock stays coarse; the highlight gets fine; the lock never depends on the fine part.

D3 — Editability is generous; re-review is bounded by the diff, not by a lock. A returned section opens for editing as a whole — the author is not locked to the single field the reviewer flagged, because a genuine correction often touches a neighbouring field (fix a qualification date, discover the certificate number above it is also wrong). Restricting the author to one field is brittle and, worse, would hide the incidental change rather than surface it. Instead: the author edits the returned section freely; the inbound diff (D2) then shows the reviewer exactly the fields that moved. If the author fixed one thing, the reviewer sees one highlight and re-checks one field; if five things moved, the reviewer sees five and re-checks five — and should, because they changed. Generous editability does not cost reviewer workload, because the diff bounds the re-read to the actual delta. (This resolves the editability-vs-workload tension directly: you do not control reviewer workload by restricting the author; you control it by telling the reviewer the truth about what moved.)

D4 — Two comparison modes: value-comparison for typed fields, upload-event comparison for documents. Not all fields diff the same way: - Typed/structured fields → value comparison against the field-set snapshot. A field counts as changed iff its value differs between the reviewed version's snapshot and the new submission. Clean and exact. - Document/upload fieldsupload-event comparison, NOT content diff. A document field counts as changed iff a new file was uploaded against that slot since the reviewed version. The system does not diff document contents — it observes that the slot received a new upload. This is coarse but true: it flags the document for re-review without pretending to understand it. Rationale: diffing document contents is the snapshot↔R2 document-versioning axis deliberately deferred (the field-set snapshot is JSON, not document bytes); this ADR must not drag that axis in. Upload-event comparison is the honest, available signal. (A stricter hash-comparison mode — so a re-upload of a byte-identical file does not flag — is a named-future refinement, contingent on file hashes being stored; not pre-paid here. See Open.)

D5 — Approved sections stay fully locked. Only returned sections open for edit (D3). An approved section is not editable on resubmission — which is why the reopen rule (ADR-053 D4) only ever re-presents returned sections to the reviewer: an approved section's content cannot have changed, because the author could not touch it. The ping-pong (author re-edits already-approved work, forcing full re-review) is closed structurally by the lock, not by trust.


The unexercised assumption (verify-don't-assert).

The typed-field diff (D4) rests on pc_attestations.snapshot containing the full field-set values to compare. This is the documented snapshot shape (ADR-053 D1: "frozen JSON of the attested field-set"; ADR-052's contact-block freeze; the store/submit brick's SNAPSHOT_FIELDS). But no real submission has ever frozen a snapshotpc_attestations is zero rows in prod and has never fired. So the snapshot-completeness this diff depends on is designed-and-documented but unexercised, not verified-in-data. Stated here as load-bearing-and-unproven: the first real submission proves the snapshot carries what the diff needs; if it does not, this ADR's D4 typed-field mode must be revisited against the actual frozen shape (truth before paperwork). Flagged so the surface build verifies the snapshot contents against a real frozen row before relying on the diff, rather than assuming it.


Consequences.

  • The per-section review surface builds the diff as a display-time read over two snapshots (the reviewed version's and the current submission's), highlighting changed typed fields and new-upload document slots within reopened sections. No new substrate, no stored diff, no diff state.
  • No "unlock"/"edit" toggle is built (D1) — returned sections are simply editable; approved sections are not.
  • The reopen/lock state machine is untouched (D2) — ADR-053 D3/D4 stand; this is display only.
  • The surface verifies snapshot completeness against the first real frozen row before trusting the typed-field diff (the unexercised-assumption flag).
  • Composes with ADR-054 at the surface: ADR-054's mandatory return-reason is what the outbound highlight carries; this ADR's diff is where the inbound highlight points. Two principles, one mechanism, two directions.
  • The assessment-judgement surfaces inherit this alongside ADR-054 — candidate↔assessor revision will carry its diff the same way, citing this ADR rather than re-deriving it.

Open / deferred.

  • Hash-comparison mode for documents (so a byte-identical re-upload does not flag as changed) → named-future, contingent on file hashes being stored; not pre-paid. Upload-event comparison (D4) is the shipped behaviour until then.
  • Sub-tree diff as a lock decision input (vs the display use this ADR makes) → remains ADR-053's parked named-future; this ADR explicitly does not promote it.
  • Document content diffing (snapshot↔R2 versioning) → the deferred document-versioning axis; out of scope here.
  • How the highlight renders (the actual visual treatment — colour, marker, the "highlighter" effect) → surface-spec; must sit within foundation's status-axis rules (workflow state is status-bearing, not integrity-bearing — green/amber/red status system, not the cool integrity palette; the parked teal reconciliation does not gate this).

This ADR is the partner to ADR-054: where part one made the verdict carry its reason, part two makes the resubmission carry its diff — the two halves of an honest review loop. The diff is observed not declared, display-only (never a lock input, so ADR-053's section-grain reopen and its parked sub-tree-diff question both stand untouched), generous on author editability but bounded on reviewer re-review by the diff itself, and honest about documents (upload-event, not content diff). Accepted 2026-06-19 — PC-SECTION-REVIEW-SURFACE-01 built the diff in code: getPendingOnboarding returns the prior reviewed snapshot (highest reviewed version below current); the surface highlights changed field-groups by value-comparison, observed-not-declared, display-only (never a lock input — ADR-053's section-grain reopen stands untouched). One honest caveat carried to the close: pc_attestations is still virgin (0 rows both envs), so the diff's first exercise against a real resubmitted snapshot is caught at the first real round-trip (the unexercised-snapshot gate), not in this brick.

Proven 2026-06-22 via the live UI return walk on dev (full evidence below) — the diff fired on the real v1→v2 resubmission (four CHANGED fields), so the unexercised-snapshot gate above is now CLOSED. The mechanism is proven in code; the operating surface is not yet presentation-ready — see the engine-vs-surface caveat on ADR-056 (2026-06-22).


ADR-056 — The section model: sections are an app-layer structure over a flat snapshot, rendered by one component through two lenses

Status: Accepted Date: 2026-06-19 Accepted: 2026-06-22 (proven in code via live UI walk; status was set early on 2026-06-19 in Brief B 04d21054 — see lifecycle-miss redline below) Relates to: ADR-053 (the two-axis submission model — this ADR closes its deferred section_key enum), ADR-054 (verdict-with-feedback atomic; pc_section_reviews is keyed on section_key), ADR-055 (the honest review loop; the diff is computed per section), ADR-047 (clause-bound fields — the trace this ADR extends with a third category), ADR-048 (no orphan grains — every field carries a trace or it has no place on the surface), ADR-044 (the /entry self-authoring gate this refactors), ADR-026 (app-layer person ref, no FK), T4B-ONBOARDING-SURFACE-01 (the five-tile model this formalises; §3, §6, §10.6, line 140), ADR-STATUS-LIFECYCLE (the flip discipline this and ADR-053/054/055 ride), the per-section review surface brick this ADR precedes. Substrate-verified at draft time (2026-06-19) against live prod workspace-db 81e2919a-6587-40a1-b749-0a65103d95f0, read from sqlite_master, by-UUID: pc_section_reviews DDL (section_key TEXT NOT NULL — enum-bounded at app layer, as designed); pc_persons DDL (29 cols, contact block — date_of_birth, residential_address, phone, email, position — ALTER-appended at tail, date_of_birth present as a column); row counts pc_persons=2, pc_attestations=0, pc_section_reviews=0. The findings below are read, not assumed.

The decision needed. ADR-053/054/055 settled how a submission is versioned, how a per-section verdict binds to its reason, and how a resubmission carries its diff — all keyed on a section_key that those three ADRs deliberately left open ("settled per-tile as the surface is built"). REVIEW-SURFACE-RECON-02 then found the harder truth: sections do not exist in any built surface. /entry is a flat phased form (~12 ungrouped fields, type Phase = "loading"|"author"|"declare"|"done"); /admin/approvals is a separate 136-line read-only grid; the two share zero components; there is no section_key structure anywhere; the five-tile model is spec-and-disposable-prototype only. So "one surface, two lenses sharing section components" (the design the review-loop ADRs assume) is a from-scratch build of the section model, not a re-skin. This ADR is the keystone the review-loop trio rests on: it fixes what a section is, where the section structure lives, how the author and review lenses share one component, and what trace every field on a section must carry. It is deliberately skeleton-only — it commits to the section architecture without committing to building the five-tile experience, USI ingestion, or qual trees.

Findings from the substrate and the recon (read, not assumed).

  1. section_key is genuinely open and genuinely the substrate's only ask. pc_section_reviews.section_key is TEXT NOT NULL, enum-bounded at the app layer — the table holds no enum members, no section→field mapping, nothing. The substrate is waiting on this ADR to say what the sections are. Nothing else in the table constrains the answer.

  2. The snapshot is flat; there is no section grouping anywhere in storage. pc_attestations.snapshot is a whole-record JSON blob (ADR-053 Finding 1); SNAPSHOT_FIELDS (onboarding.ts:44) is a flat field list with no grouping; pc_persons is a flat 29-column table. Sectioning is therefore not a storage fact — it is imposed in the app. The field→section_key map is app-layer, by necessity, not by preference.

  3. The two surfaces share nothing. /entry (author) and /admin/approvals (review) are independent pages built on different code with zero common components (RECON-02). The "one component, two lenses" thesis is real refactor work — build the shared component, move /entry onto it, render the review lens from the same component — not a styling pass over something that already exists.

  4. The snapshot has never fired (pc_attestations=0). No production row constrains the section model; it lands clean. (And — flagged for the surface build, not this ADR — the contact-block fields have therefore never been frozen in a real snapshot; the ADR-055 typed-field diff must be verified against the first real frozen row before it is trusted. ADR-056 does not depend on that; it defines structure, not diff behaviour.)

  5. DOB is stored but never collected or frozen. pc_persons.date_of_birth exists as a column (PERSON-CONTACT-MIGRATION-01, ADR-052) but is not on /entry and not in SNAPSHOT_FIELDS (Alex's Gate-1 finding, SNAP_LABELS fix). It is a column with no path to fill it — the worked example this ADR uses to settle the third trace-type.

  6. Stale DDL comment flagged, not fixed here. pc_attestations.attested_by still carries -- canonical usr_… (ADR-024) in prod; ADR-053's redline corrected the ADR text but the DDL comment remains. The column is TEXT, enforcement is app-layer, so this is cosmetic-but-dishonest (it asserts a format the redline overturned). Out of scope for this ADR — it rides the surface build's migration or its own one-line tidy. Recorded so it is not lost.

The proposal.

D1 — The five sections, compliance-language verbatim, closing the section_key enum. The section_key enum (open since ADR-053 D3) is closed to exactly these five members, named in the sector's own vocabulary per the T4B "compliance language verbatim" call (T4B-ONBOARDING-SURFACE-01 §3, §68 — the trainer learns the sector's actual words; tiles map 1:1 to operator/audit framing; no translation layer to drift):

identity_suitability   -- who you are + suitability to hold the role (legal name, USI, WWCC, fit & proper, work rights)
credential             -- the teaching qualifications held (people-02-spec §5)
vocational_competency  -- the units competent to deliver (people-02-spec §6)
industry_currency      -- currency in the industry of delivery (people-02-spec §7.1)
ta_currency            -- currency in training & assessment practice (people-02-spec §7.2)

These are stable identifiers (snake_case keys, not display labels — the human-facing label and its plain-English subtitle are presentation, per §3). The enum is closed here because the review-loop trio cannot prove themselves against an open enum: a verdict keyed on section_key needs the key-set fixed. Adding a sixth section later is a deliberate enum change with its own ADR amendment, not an open extension point.

D2 — A "section" is a named field-group, app-layer, inheriting ADR-053 D3 unchanged. A section is a fixed group of fields, identified by section_key, imposed in the app over the flat snapshot. This is not a new definition — it ratifies ADR-053 D3 ("a section is a fixed field-group, not a snapshot sub-tree diff") now that Finding 1 confirms the snapshot carries no grouping of its own. The field→section_key map is app-layer data (a static, authored map, not runtime-derived — same posture as the clause trace in ADR-047 §2): every field the surface renders declares which section it belongs to, alongside its name, type, and trace. Sub-tree diffing within a section remains named-future, not pre-paid (ADR-053 D3, ADR-055).

D3 — One component, two lenses: the author/review split is structural, not two pages that look alike. Both /entry (author) and the per-section review surface (review) render through one shared section component. The component takes a section (its field-group + each field's trace) and a lens: - Author lens — fields are editable; the section participates in save (free) and submit (freezes a snapshot version, ADR-053 D1). This is the T4B person authoring their own record. - Review lens — fields are read-only on content (the reviewer can never edit the trainer's attested facts — doing so would co-author the attestation and destroy self-attestation integrity; this is load-bearing, carried from the review-loop model); per-section verdict controls are added (approve / return-with-reason, ADR-054); the section shows its derived approved/returned/locked state (ADR-053 D4) and, on a resubmission, the highlighted delta (ADR-055).

This is the recon's correction made structural: the surfaces currently share nothing, so this ADR commits to building the shared component and moving /entry onto it — net-new component plus an /entry refactor, not a re-skin of /admin/approvals. The read-only boundary on the review lens is the integrity guarantee the whole review loop depends on.

D4 — Every field on a section carries a trace; there are three trace-types. ADR-047 established that every field on a regulated surface carries a stored trace (anchor / obligation / consequence), authored as fact, never generated. ADR-048 established that nothing enters a regulated surface unanchored. Building the section model surfaces a category ADR-047 implied but did not name: not every field on a regulated surface is itself clause-bound, yet none may be an orphan grain. Three trace-types resolve this:

  1. Clause-bound (ADR-047, unchanged) — the field is directly traced to an instrument clause: a stored anchor (e.g. 3.2(b) within F2025L00354), obligation, and consequence. The field answers a clause. Example: the fit-and-proper declaration, the WWCC fields — the suitability checks themselves.

  2. Product-mechanics — the field has no compliance connection; it exists to make the product work. It carries a trace that honestly says so (type = product-mechanics; no anchor, because fabricating one would be a no-orphan-grains violation in reverse — a false clause is worse than an honest "none"). It is non-gating by definition. Example: position (T4B-ONBOARDING-SURFACE-01 line 140, explicitly non-gating, non-clause-bound); preferred_name.

  3. Supporting identifier (NEW — the category this ADR names) — the field is not clause-bound itself, but is the identifier or enabler that one or more clause-bound checks depend on. Its trace is to the checks it enables, not to a clause it answers (fabricating a direct clause would violate no-orphan-grains in reverse, per type 2's logic). It is held-not-gating: a missing supporting identifier flags incompleteness but never, on its own, turns a dimension red. Any secondary product use (e.g. identity-verification, password recovery) is a free benefit, never the justification — the justification is always the clause-bound check it serves.

This third type is worth naming at ADR altitude because it recurs — every regulated surface has identifiers that enable checks without being checks. Naming it now stops each future tile re-deriving it (or worse, mis-filing a supporting identifier as either a fabricated clause-bound field or a dismissed product-mechanics field).

D5 — DOB is the worked example of the supporting-identifier type, in identity_suitability. Tim's decision (2026-06-19, instruments researched): collect DOB. The modelling:

  • DOB is not directly mandated by F2025L00354 or the Credential Policy. There is no clause that says "record date of birth." Filing it as clause-bound would fabricate an anchor — a no-orphan-grains violation in reverse.
  • Its compliance weight is derived. DOB is the identifier the fit-and-proper-person check (mandated on trainers/assessors) and the WWCC (where minors are in scope) key off — both are clause-bound checks in this same section, both visible as columns in pc_persons (fit_proper_declaration_date, wwcc_*), and neither carries an identity anchor without DOB. DOB's trace is therefore to those checks, type = supporting identifier.
  • Payroll/tax also need DOB — but that is employment law, outside the compliance spine, and is explicitly NOT the justification. Using it would file DOB against the wrong authority.
  • Held-not-gating. A missing DOB flags the identity_suitability section incomplete; it never alone turns the dimension red. (Contrast WWCC, which can gate.)
  • Descriptive-but-substantive — heavier than position, lighter than WWCC. No evidence-upload: you do not verify a DOB with a document the way you verify a WWCC. It sits between product-mechanics (position) and a clause-bound evidence-bearing check (WWCC).
  • Identity-verification / password-recovery use is a free secondary benefit, not its justification (D4 type-3 rule applied).

Consequence for the build: to honour the decision, DOB must be added to both /entry (collect) and SNAPSHOT_FIELDS (freeze/attest), as part of building the identity_suitability section — it is a column today with no path to fill it (Finding 4). This is not a separate brief; it folds into building the Identity & suitability section in the surface build.

D6 — Scope fence: the skeleton, not the experience. This ADR commits to the section spine — the five section_keys, the shared two-lens component, the app-layer field→section map, the trace discipline. It does NOT commit to, and the proving brick does NOT build: - the five-tile landing experience (glass/video chrome, the §3 list-vs-glass treatment); - the shared "Your qualifications" USI/TGA ingestion step (ADR-050; its own later brick); - qual trees, unit-level ingestion, the integrity-colour qualifications tiles (gated on the parked foundation.md teal reconciliation — not this surface, which is status-axis); - the full per-field field→section map for all ~29 pc_persons columns + snapshot fields.

Build the shelves, do not stock them. The proving brick builds the section structure, the one shared component, both lenses on it, and identity_suitability populated enough to exercise the model end-to-end (including DOB). The other four sections exist as structure; their full field content is later, separate bricks. This fence is written into the ADR so the surface build cannot quietly expand into the experience.

D7 — The full field→section map is a build-brief artefact, not fixed here. This ADR fixes the five section_keys (D1), the assignment principle (every rendered field declares its section_key and its trace-type, D2/D4), and DOB worked completely as the supporting-identifier exemplar (D5). The complete per-field map — which of the ~29 pc_persons columns and which SNAPSHOT_FIELDS entries land in which section, each with its trace — is authored in the surface build brief, against this ADR's framework, because it depends on which fields the skeleton actually renders (and the skeleton is identity_suitability-deep, not all-five-deep). Pinning a full 29-field map here would over-commit the ADR to fields the skeleton will not surface and would couple the keystone to content decisions that belong with the build. The framework is canon; the map is build.

Consequences.

  • The section_key enum is closed to five members (D1). ADR-053/054/055's inherited "settled per-tile" open item resolves here for the People module; the enum is fixed canon, extensible only by ADR amendment.
  • The proving brick builds the shared two-lens component and refactors /entry onto it (D3) — net-new component + /entry refactor, confirmed by RECON-02 (zero shared components today).
  • The field→section map and each field's trace are app-layer authored data (D2, D7), authored in the surface build brief against this framework.
  • The three trace-types are canon (D4); future regulated tiles cite them rather than re-deriving. The supporting-identifier type is the load-bearing addition.
  • DOB is collected — added to /entry and SNAPSHOT_FIELDS as part of building identity_suitability (D5), not a separate brief.
  • The scope fence is canon (D6) — the surface build is skeleton, not experience; the fence is citable if the build tries to expand.
  • This ADR is Proposed and flips with the trio. Per ADR-STATUS-LIFECYCLE, ADR-056 is agreed-but-unproven-in-code. The per-section review surface brick proves it (the section model runs on a real submission) and flips it Proposed→Accepted in that brick's own commit — the same brick that flips ADR-053, 054, and 055. One brick, potentially four flips; verify each as-built matches, amend if substrate forced a divergence (truth before paperwork).

Alternatives considered.

  • Leave section_key open, let the surface build define sections inline. Rejected: the section model is a load-bearing structural decision (the review-loop trio rests on it); burying it in a build brief instead of an ADR is exactly the failure ADR-053/054/055 deferred to avoid. The keystone belongs in canon.
  • Group the snapshot into sections in storage. Rejected (Finding 1): the snapshot is a flat whole-record blob and pc_persons is flat; imposing storage-level grouping buys structure and a JSON-path mapping the tables do not carry, before review churn proves it needed (the same reasoning as ADR-053 D3's rejection of sub-tree diffing). Sectioning is app-layer.
  • Two parallel pages (author, review) that look alike. Rejected (D3, Finding 2): they currently share nothing, so "look alike" would mean maintaining the section model twice, drifting apart the first time one is touched. One component, two lenses, is the only honest way to guarantee the author and reviewer see the same sections.
  • File DOB as clause-bound (find it a clause), or as product-mechanics (no trace). Both rejected (D5): the first fabricates an anchor (no-orphan-grains in reverse); the second dismisses a field that genuinely enables mandated checks. The supporting-identifier type exists precisely because neither extreme is honest.
  • Pin the full field→section map in this ADR. Rejected (D7): over-commits the keystone to content the skeleton will not surface; couples the framework to per-field build decisions. Framework here, map in the build.
  • Build the five-tile experience now. Rejected (D6): the experience (ingestion, qual trees, glass chrome, integrity colour) is gated on later bricks and a parked teal reconciliation; building it now stocks shelves before the shelves are proven to hold. Skeleton first.

Open / deferred.

  • The full field→section map + per-field traces → the surface build brief, against D1/D2/D4/D7.
  • pc_attestations.attested_by stale usr_… DDL comment (Finding 5) → out of scope; rides the surface build's migration or a one-line tidy.
  • Snapshot completeness against the first real frozen row (Finding 3) → the surface build verifies before trusting the ADR-055 diff; not an ADR-056 dependency.
  • The four non-identity sections' full content (credential, vocational_competency, industry_currency, ta_currency) → structure now, content in later separate bricks (D6 fence).
  • role multi-hat (T4B §10.6, possibly single-enum → multi-valued) → its own change; touches identity_suitability content but not the section model.
  • A sixth section, or sub-tree diffing within a section → ADR amendment / named-future respectively, not pre-paid.

This ADR is the keystone the review-loop trio rests on: it makes "section" a real structure where REVIEW-SURFACE-RECON-02 found none, closes the section_key enum to five compliance-language members, makes the author/review split one component through two lenses (the recon found zero shared components — this is real refactor work, not a re-skin), and names the third trace-type (supporting identifier) that building the section model forced into the open, with DOB as its worked example. It fences the build to the skeleton, not the experience. Accepted 2026-06-19 — PC-SECTION-REVIEW-SURFACE-01 proved it: the shared two-lens RecordSection component is built (the recon found none), /entry is refactored onto it, identity_suitability is built whole (the 22-field Accepted map incl. DOB collected as a supporting-identifier), the other four sections stand as structure-only shelves, and the five section_keys are enum-bounded app-layer. Flipped alongside ADR-054 and ADR-055; ADR-053 was already Accepted (its version-axis substrate shipped earlier), so this brick flipped the remaining trio — one brick, three flips.

Proven 2026-06-22 via the live UI return walk on dev (full evidence below). The mechanism is proven in code; the operating surface is not yet presentation-ready — see the engine-vs-surface caveat below.

2026-06-22 — Accepted-at-engine-level is not presentation-ready (caveat on 054/055/056). These ADRs are Accepted because the entry/approval state machine was proven end-to-end on dev: invite → author v1 → sign → lock → empty-reason return REJECTED → return-with-reason COMMITTED (atomic verdict+reason) → invitee reopen with reason shown + form editable → edit → resubmit v2 (append-only, v1 preserved) → diff v1→v2 (four CHANGED fields, display-only) → approve → co-sign → exactly one T4A grant minted. Every transition substrate-verified by UUID.

Accepted means the mechanism is correct and enforced in code. It does NOT mean the admin operating surface is usable. A human admin currently CANNOT follow this workflow by sight. Known, named, un-actioned legibility gaps: - BUG-0/admin/approvals is a floating URL: no tile, no entry point, no pending-submission signal (confirmed: admin home carries no Approvals tile while a submission sat pending). - BUG-LOCK-BADGE-FALSE-APPROVED-01 — invitee /entry renders "APPROVED · LOCKED" while the record is merely submitted/in-review (zero section_reviews). False status on a compliance surface. - PC-APPROVALS-CORRESPONDENCE-BURIED-01 — the return reason / correspondence thread is captured and rendered, but collapsed-by-default and below the co-sign footer. Admin sees NEEDS MORE but not what was said without hunting. - Per-section correspondence + status-colour semantics — agreed direction: hang each section's correspondence under that section (five sections = five threads) AND keep the growing flat log at the bottom for print/export. Colour semantics discussed, not settled (linear vs actor-based model open).

This caveat must not be read as closing those gaps. The approvals-surface IA + status-semantics spec is the designated next brief. Engine-truth and surface-gap are separate sign-offs: proven-in-substrate ≠ legible-to-the-operator.

2026-06-22 — Lifecycle-discipline redline: 054/055/056 were flipped Accepted early. Recorded honestly rather than buried. ADR-054/055/056 were filed Proposed (957c3293 / f31d914b) and flipped to Accepted in Brief B (04d21054, 2026-06-19) — a brief that built the per-section surface scaffolding, not the proven review loop. ADR-STATUS-LIFECYCLE requires proof-in-code to flip a status, not agreement and not adjacent scaffolding. The proof-in-code event for these three is today's live UI return walk (2026-06-22), three days after the status was set. The early flip was benign — the walk retroactively justifies Accepted — but it is a miss against the discipline, logged here so the pattern is visible and not repeated. The lesson: building the substrate or surface a decision describes is not proof the decision works; only exercising the decision through to its terminal effect (here: the single T4A grant) is. Status stays Accepted (now genuinely earned); the date provenance above (Gate 2a) carries the gap.


ADR-057 — The sign-in gate is three explicit admits: the invite (or grant) IS the authorisation, not a second allowlist

Status: Accepted (flipped from Proposed 2026-06-29 — see Proving brick; owed maintenance that should have ridden the ship commit) Date: 2026-06-19 (Accepted 2026-06-29) Relates to: ADR-024 (the magic-link issuance gate this refines), ADR-044 (the /entry self-authoring gate — an invite stays pending through login, so the invitee must be able to sign in off it), IMM-01 Phase 3c (the migration that moved the login reader to magic_link_allowlist but left the invite writer on access_allowlist — the seam this ADR closes), PC-AUTH-SIGN-IN-GATE-01 (the brick that proves it).

The decision needed. Magic-link sign-in is gated: only an allowed email is ever sent a link. A walk of the real invite→login flow surfaced that a freshly-invited person could not sign in — "by invitation only", no link. Root cause: two allowlists that were meant to agree, didn't. The invite act wrote the invitee's email to access_allowlist (ops-db); the login gate, since IMM-01 Phase 3c, reads magic_link_allowlist (identity-db). The migration moved the reader and not the writer, so every new invitee was allowlisted in a table the login no longer reads — dead-on-arrival at sign-in. The question was how to reconnect invite → login.

The decision. Do not re-sync the two allowlists — collapse the redundancy. A pending portal_invites row already is the statement "this email may enter"; a non-revoked tier_grant already is "this person is a member." Maintaining a second allowlist that has to be kept in lockstep with those tables is double-bookkeeping, and double-bookkeeping is exactly what drifted here. The sign-in gate (/identity/sign-in-check, formerly magic-link-allowlist-check) becomes three explicit admits, deny-by-default:

  1. operator/seed allowlist — a magic_link_allowlist email- or domain-row (is_active). The bootstrap/escape hatch for operators and seeded accounts.
  2. an open invite — a pending|returned portal_invites row for the email. The first-login path (the invite is the authorisation).
  3. an active grant — a non-revoked tier_grant for the email's user. The returning-member path.

Each admit is an independent positive-match branch: a branch can only ever contribute an admit (a row found), never on absence; and because any lookup error throws the whole check (the caller's catch → false is preserved), no branch's failure can fall through to admit. The gate admits iff at least one branch positively matches, and denies on nothing-matched or any error. The invite flow stops writing any allowlist (both orgs.ts access_allowlist writes removed) — the invite/grant tables are the single source of truth.

Why D over re-pointing the writer (the rejected alternatives). Re-pointing the invite writer at magic_link_allowlist (option A), or reading both tables transitionally (option B), both keep the two-allowlist structure and so preserve the fragility — the next migration can desync them again. D removes the class of bug, not the instance. (Tim's call, 2026-06-19.)

Returning members must not lock out (the load-bearing addition). The gate fires at the magic-link request step, before any session exists — so it governs every login, including returning approved members. An approved invite is correctly not "open" (its work is done; the grant carries the authorisation now), so admit #3 (active grant) is required, or an approved member with no allowlist row would be locked out on their next login. The three admits together cover all and only the legitimate paths.

Fail-closed, and the forever-invariant it rests on. "Open" is the explicit status IN ('pending','returned') enumeration (the same definition getOpenInvite uses) — deny-by-default, so a cancelled / expired / approved / unknown status is not open. This is only safe while that holds:

INVARIANT (load-bearing, forever): any future invite cancel / revoke / expire path MUST move the row to a non-open status (or delete it). A cancel that left a row reading pending/returned would let a revoked person sign in — strictly worse than the bug this ADR fixes. The open-check is an explicit allowlist of open states precisely so unknown/new states fail closed; the one thing that breaks it is a cancel that doesn't close the state. (Candidate standing-rule.)

Inherited, not introduced (pre-existing gaps). There is today no invite cancel path and no invite expiry — a pending invite lingers indefinitely and admits. This is unchanged from how submit/approve already treat lingering pending; D inherits it, does not worsen it. Optional time-bounding (invited_at > now − window in the open-check) is a candidate for the follow-on, not required for correctness.

Consequences. - One gate endpoint, renamed to say what it does (/identity/sign-in-check, "may this email sign in?"). - access_allowlist (ops-db) has no remaining reader and no remaining writer. Follow-on C (finish IMM-01 Phase 3c): retire the access_allowlist table and data-hygiene the invitee emails it accumulated. Owed — do not leave Phase 3c half-finished again. - No schema change. Pure logic + the two write removals. - Revoke path (PC-INVITE-REVOKE-01, 2026-06-19): the first cancel/revoke path now exists — revoke sets status='revoked' + revoked_at, moving the row outside the open-check. The forever-invariant above is honoured by construction, not by new gate logic.

Proving brick / status. Accepted — the flip condition is met and double-proven. (1) PC-AUTH-SIGN-IN-GATE-01 shipped the three-admit gate (0660f31d). (2) Re-proven live this session (2026-06-29): during the canonical mint of the Aidan-Jones dev fixture (BRIEF-PEOPLE-ROLE-CORRECTIVE-01 §4), an invited person signed in off their pending invite — admit #2 fired exactly as specified (the magic-link request resolved against the pending portal_invites row, no allowlist row needed). The owed Proposed→Accepted flip should have ridden the ship commit (maintenance-on-write); it is recorded here at the canon-closure tidy. Follow-on C (retire access_allowlist, finish IMM-01 Phase 3c) remains owed — separate.

Found by walking the real invite→login flow for the first time — a seam broken in shipped code since IMM-01 Phase 3c, not introduced by any recent brick. D collapses the double-bookkeeping rather than re-syncing it; the invite/grant tables are the authorisation, the allowlist is only the operator escape hatch.


ADR-058 — Seat enforcement and churn: a seat is consumed by grant-or-invite, freed on revoke, and reactivation is cooldown-gated so the cost follows the cut

Status: Accepted (flipped from Proposed 2026-06-26 at the move-3 drip-2 walk — see Proving brick) Date: 2026-06-25 (Accepted 2026-06-26) Relates to: ADR-020 (T4A is the countable seat; T4 admin role is not seat-consuming), ADR-021 (plan/entitlement/metering substrate — seat_limit is the entitlement this enforces; this ADR may extend it with the cooldown entitlement and the hand-back count), ADR-023 (graceful degradation / deactivate-not-delete — the dormant T4A user whose reactivation this gates), ADR-032/038 (seat_limit/admin_only_limit live in ops-db, read via the internal-api seam per OPS-SURFACE-RULE; lifecycle derives from the grant, revoked_at = deactivated), ADR-044 (the pending⇄returned⇄approved machine — "approved" is the threshold the revoke split rides), ADR-057 (the revoke path sets status='revoked' — the clean-free invite side), SIGNAL-LANGUAGE-01 (the dormant-account countdown is a Waiting state → warm axis), SEAT-ENFORCEMENT-AND-CHURN-01 (the brief that builds and proves this, phased A/B/C), SEAT-SEAM-CONTRACT-01 (the bank doc capturing the T3 side of this seam).

The decision needed. seat_limit is stored (ops-db, real value — dev client = 4) but unenforced: the live roster shows "5 of 4 seats" with no block. Worse, three surfaces derive "is this a seat?" independently on two different bases and disagree (the team badge and the frontend count count membership-type incl. pending invites → 5; the backend cap counts non-revoked T4A grants only → 4). Pre-revenue this is cosmetic; at the first paying multi-seat org it is lost revenue (a 4-seat customer runs unlimited bodies free). And a naive "revoke frees a seat, reactivate is free" enables hot-seating — rotating more bodies than seats by alternating who is active. The question: how does T4 enforce the limit honestly, close the over-issue race, and prevent hot-seating — without T4 deciding any commercial number (those are T3's)?

The decision. Four commitments, all T4-side; T3 owns the numbers, T4 honours them.

1. One seat predicate, spanning grants AND invites. "Does this account consume a seat?" is answered in exactly one place, consumed by the badge, the count, and the cap alike (the three may no longer disagree — the "5 of 4" is two surfaces re-deriving one fact, the count-drift class, again). An account consumes a seat iff its role is seat-consuming (member | admin_member, per the taxonomy decided with Jimmy 2026-06-24, foundation §1.9) AND it either holds a non-revoked T4A grant or holds an outstanding unaccepted seat-consuming invite. The predicate spans both tables by necessity — counting grants alone cannot reserve on invite. This is the keystone; it is a small design, not a one-line extraction.

2. Reserve on invite. Issuing a seat-consuming invite consumes a seat immediately, before acceptance. This closes the over-issue race (today the cap is checked at invite but the invite mints no grant, so N concurrent invites all read "room" and all write). An outstanding unaccepted invite holds a seat until revoked or expired.

3. Revoke divides on the approval threshold — and the split is structural, not a runtime branch. Confirmed at Gate 1: the two paths are already two different controls, so "was this approved?" is answered by which control fired, never a query. - revokeInvite operates only on pending|returned invites — never-approved, no grant ever existed → the seat frees clean, no cooldown. (The "phoned and pulled out of the job" case.) - deactivateMember / updateMember(status=deactivated) operate on grants, which exist only post-approval → the seat frees and the member becomes a cooldown candidate. The clock does NOT start here. revoked_at (stamped at deactivation, ADR-038) decides only the FIFO order in which candidates lock; the clock starts at seat-CONSUMPTION — the instant a freed seat is taken by another body (the move-3 mechanism below, built drip 1) — stamping cooldown_expires_at on the oldest still-free candidate. The cooldown gate attaches to exactly the reactivate arm — updateMember(status=active), which today clears revoked_at unconditionally; it will clear it (and cooldown_expires_at) only when the gate is satisfied. (Supersedes the original "clock's origin is revoked_at" phrasing — revoked_at is order, consumption is the clock; corrected at the move-3 build, 2026-06-25.)

The two paths are two operations (A) and (B): named, never unified. The split in commitment 3 is operational, not just a seat-accounting difference. The clean-free path and the cooldown path are not one "reinstate" with a branch; they are two operations, on two surfaces, over two substrates, and the symmetry of the word "reinstate" is a trap (Gate 1 finding, 2026-06-25: a single spanning reinstate was specced and would have no-op'd on grantless fixtures — the invite side has no grant to clear, the member side has no invite to re-open). - (A) Reinstate a revoked invite. Operates on a pending|returned|revoked invite row (no grant ever existed). Lives on the team page (the roster row's own control). Clean-free: no grant to mint-back, no cooldown, only the seat-reserve to re-take (subject to the cap → remedy socket if at limit, per commitment 4's purchase path). Built and walked 2026-06-25 (TEAM-SURFACE-STABILISE-01 invite-reinstate; at-limit case TEAM-SURFACE-WALK-FINDINGS-03 F1). - (B) Reactivate a deactivated member. Operates on a tier_grants row with revoked_at set (the grant existed and was deactivated post-approval). Lives in the person drawer, not the roster row — a deactivated member is a dormant account with a countdown, not a pending invite. Cooldown-gated per commitment 4. This is Phase B.

The rule: there is no operation that spans both. revokeInvite / invite-reinstate is the (A) pair; deactivateMember / updateMember(status=active) is the (B) pair. "Was this ever approved?" is answered by which surface and which substrate the operation touches, never by a runtime query — the structural split commitment 3 already commits to. A future brief proposing a unified "reinstate" control is wrong by this ADR and must be split before build.

Substrate prerequisite for (B): a deactivated member must render before its reactivation can be gated or counted-down. Today getMembers filters revoked_at IS NULL, so deactivated grants vanish from the roster entirely — no row to hang the countdown on. The Revoked roster section (foundation §1.9, v1.32) is (B)'s home; surfacing deactivated members there is the first Phase B move, before the cooldown gate.

Amendment, 2026-06-25 — commitment 3 amended with the named (A)/(B) operational split per the Gate-1 spanning-reinstate finding; Phase B substrate-prereq (surface deactivated members in the §1.9 Revoked section) recorded. Sharpening, not a status change — Status stays Proposed.

4. Reactivation is cooldown-gated with two unlock paths, both requiring a free seat. The cost follows the cut. A previously-approved-then-revoked account reactivates only when: - (cooldown lapsed AND a seat is currently free) — free reactivation. Cooldown lapsing alone is not sufficient: at the limit with no spare seat, a lapsed account still cannot return (nowhere to put it). The only way a seat frees is revoking someone else — which starts their clock. This is the anti-gaming mechanism: the time penalty always lands on whoever was just revoked, so the org is never all-clear, and slow-motion rotation in cooldown-length increments is unworkable. OR - (a seat is purchased) — immediate, cooldown skipped. Paying is the honest signal that the capacity is genuinely needed; the purchase routes through the remedy socket (below). The bought seat is the admin's free choice of any locked person — not earmarked, not auto-assigned: a purchased seat is just capacity, spent on whoever the admin picks. Five locked + one bought seat = the admin chooses one. This falls out of the gate for free (reactivation is already a per-member admin action against available capacity); no earmarking is built.

No legitimate-leaver carve-out (Tim + Jimmy, 2026-06-25). There is no admin-asserted "this person really left, skip the cooldown" override, by design. A T4A seat is active involvement in compliance-related surfaces — real regulated work — so consuming/releasing a seat is not a cheap convenience toggle; reactivation is a genuine new consumption event with weight. A real leaver genuinely left; bringing anyone back is genuinely new consumption. The cooldown therefore applies to every approved-then-revoked reactivation, full stop, and buying a seat is the only express lane. This removes an entire class of mechanism (no override to authorise, audit, or abuse). Recorded with its reasoning so it is not re-litigated.

Refusal opens a stubbed remedy socket with a return-to-the-same-spot mechanic. Any new consumption past seat_limit (a fresh seat-consuming invite, or a pay-to-play reactivation) is hard-refused — not warned — into an in-place overlay that, on a "limit increased / seat applied" signal, re-checks and completes the original action at the same spot (no re-navigation). One socket, two entry points (over-limit "buy a seat"; skip-cooldown "buy a seat to bring this account back now"). The socket content is a stub today — there is genuinely nothing to wire (ADR-034: prod billing fails closed, no Stripe/QB keys by design). T4 builds the socket and the return mechanic; T3 fills the purchase later.

The numbers come from the seam, never hard-coded. T4 reads seat_limit (already on the established getMembers→seats-envelope seam, the OPS-SURFACE-RULE-compliant path) and seat_cooldown_period (no column exists yet — the seam returns nothing, T4 enforces a clearly-marked stub default until T3 adds it). T4 also hands back the live seat-consumption count on the same envelope, so T3/billing can read usage and price an upgrade. The hand-down is the limit + cooldown; the hand-back is the usage. Two directions, one seam.

Why this shape (rejected alternatives). - Count active grants only (no reserve-on-invite). Rejected: leaves the over-issue race live — concurrent invites against the cap all succeed. - Free reactivation always (no cooldown). Rejected: enables hot-seating, defeats the limit without ever exceeding it. - Detect/judge gaming (fraud heuristics, intent flags). Rejected: brittle, and unnecessary — the economic deterrent (cost-follows-the-cut) makes honest cheaper than dishonest, so the incentive does the work with two numbers and a clock; no surface ever has to decide "this looks suspicious." - Carve-out for genuine leavers. Rejected (see above) — a compliance seat is not a convenience to waive. - Read seat_limit directly from ops-db on the surface. Rejected — violates OPS-SURFACE-RULE; the seam is mandatory, not a convenience.

Consequences. - The seat predicate becomes one home spanning grants + invites; badge / count / cap consume it and can no longer drift. The "5 of 4" closes at source. - Reserve-on-invite is new work (today's invite holds nothing). The over-issue race closes. - Move-3 mechanism (the cooldown gate, built drip 1 of 2, 2026-06-25). The clock starts at seat-consumption, not deactivation. New nullable column tier_grants.cooldown_expires_at (INTEGER epoch, UTC by construction — matches granted_at/revoked_at; a second timezone is a pure display change) on the T4A grant (the seat). One column carries both the drip-2 revoked sub-states and the FIFO predicate: revoked_at NOT NULL, cooldown NULL = revoked-free (reinstatable); cooldown NOT NULL = revoked-locked. Three consumption triggers (every event that raises seatConsumption.used): invite-reserve (incl. reinstate), member reactivation, and promote-to-T4A — NOT approval (reservation→grant is net-zero). On each, the capacity-invariant guard fires: lock the oldest free-deactivated candidate only while used + free-deactivated > seat_limit — consuming genuinely-spare capacity locks no one (a literal "fire on every consumption" would wrongly penalise that case). The reactivation gate is server-side truth: a locked member returns only through the wait door (now >= cooldown_expires_at AND a seat free) or the buy door (a purchased seat — stub, ADR-034); else refused (COOLDOWN_ACTIVE / SEAT_LIMIT). - Per-tier cooldown flex is a deferred billing knob. seat_cooldown_period is a stubbed flat 7 days (SEAT_COOLDOWN_PERIOD_SECONDS = 604800, the named intended value, not a placeholder). Cooldown length is a candidate per-tier billing lever; deferred until the billing engine exists (no hand to turn the dial — ADR-034); flat 7 days until then. No dial is built now. - promoteMember cap-guard gap closed (2026-06-26). Phase A guarded five seat-consuming claimers, but promoteMember (T4B→T4A) was missed — it minted a counted seat with no cap check, over-issuing with zero concurrency (surfaced by SEAT-ATOMICITY-RECON-01). Guarded under BRIEF-PROMOTEMEMBER-SEAT-GUARD-01: idempotency-aware (a re-promote of an already-T4A account is a no-op, not cap-checked), refusing SEAT_LIMIT via the same seatConsumption seam. This closes the no-concurrency over-issue onlypromoteMember stays read-then-act; the TOCTOU race (all claimers) is a separate architectural brief, and the promote return-to-spot remedy socket is queued (BRIEF-PROMOTE-SEAT-SOCKET-01). - Invite expiry built (2026-06-26) — the "or expired" release path is now real. Commitment 2 reserves a seat "until revoked or expired"; "expired" was specified but never built, so a sent-and-ignored invite held its seat forever (surfaced by SEAT-ATOMICITY-RECON-01 Q4 — the aspirational clause). Built under BRIEF-INVITE-EXPIRY-01: a 7-day fixed window, cron-driven (Option B — hourly Cron Trigger on internal-api, the worker's first scheduled surface; chosen over lazy expire-on-read so the seat-count predicate stays unchanged — an expired invite is just a non-pending row, exactly like a revoked one, which keeps expiry cleanly separable from the seat-count-atomicity redesign still to come). Only pending lapses (returned is live correspondence); expired invites stay visible in the roster's own Expired section (SIGNAL-LANGUAGE-01 — a freed seat is legible, not a silent disappearance). Per-org expiry window is a deferred seam (flat 7 days, like the cooldown stub). - Convert path made atomic (2026-06-27) — approveOnboarding mint+flip is now one batch(). The invite→grant convert was an unbatched sequence: mintGrantsForInvite (B, grant INSERT) then UPDATE …status='approved' (C, the invite leaves pending) then sibling-revoke (D), as separate statements. Between B and C the account held both a counted grant and a counted pending invite — a transient double-count, and a durable one (a seat leak) if the request died mid-sequence. Closed under BRIEF-CONVERT-PAIR-BATCH-01: mintGrantsForInvite now does the is_primary read + returns the bound INSERT statements (pre-batch — batch() can't flow data between statements), and approveOnboarding composes batch([...grants, flip, revokeSiblings]) as one all-or-nothing call; minted/grant_ids derive from per-statement meta.changes (descriptors ∥ grant slots, the flip/revoke tail untouched). ON CONFLICT DO NOTHING preserved → idempotent. This is the within-request atomicity fix only; the cross-request cap race (seatCapReached→batch straddle) is ADR-060's per-org DO, deliberately untouched here (one concern per brief). - seat_cooldown_period has no ops-db column yet — adding one is T3's call, captured in SEAT-SEAM-CONTRACT-01. T4 enforces a stub until then. - The remedy socket is built and returns-to-spot; its purchase content is a deliberate stub (ADR-034). Two named entry points so it serves over-limit and skip-cooldown alike. - The dormant-account drawer shows a live "reactivation available in Xd Yh — or buy a seat now" countdown off revoked_at + seat_cooldown_period (one source with the gate). Per SIGNAL-LANGUAGE-01 it is a Waiting state → warm axis, not Ready-green. - Work-reassignment on a vacated seat is not decided here — banked as SEAT-VACATION-AND-WORK-REASSIGNMENT-01 (a Jimmy compliance call touching Studio authored content, the person's own natural-person compliance record, and T4B). Today: the seat frees, the records sit untouched. - May extend ADR-021: the cooldown is a new entitlement-shaped number, and the hand-back count is a new metering read. If the extension is material it rides this ADR's amendment rather than opening new.

Proving brick / status. Proposed — agreed but unproven in code. Built phased under SEAT-ENFORCEMENT-AND-CHURN-01: Phase A (one-source predicate + reserve + refuse-into-socket — kills "5 of 4" and the race), Phase B (cooldown clock + reactivation gate + warm countdown drawer — the churn half), Phase C (seam hand-back). Per ADR-STATUS-LIFECYCLE this flips to Accepted when the churn model is exercised to terminal effect on dev — a seat-consuming invite past the limit is refused into the socket, and a cooldown-gated reactivation is blocked then released (by lapse-with-free-seat and by pay-to-play) — i.e. Phase B's walk, not Phase A's construction. Building the predicate or the socket scaffolding is not proof the churn model works; only driving a refusal and a gated reactivation through to effect is. Phase B is itself sequenced — move 1 (surface deactivated members), move 2 (the deactivate control), move 3 (the cooldown gate). Move 3 is two drips: drip 1 the lock mechanism + server gate (built 2026-06-25), drip 2 the drawer surface (padlock + countdown). The Proposed→Accepted flip lands at the END of drip 2's walk — a gated reactivation blocked then released, surfaced through the live countdown — not on drip-1 construction. FLIPPED → Accepted 2026-06-26: drip 2 built + walked to terminal effect on dev (Tim's eyes) — the self-ticking amber countdown drops a minute on-screen with no reload, a locked-member reactivation is refused in-UI (COOLDOWN_ACTIVE), a free-member reactivation succeeds, and seat enforcement fires on the reinstate path (4-of-4 → remedy socket). The churn model is exercised end-to-end; the proving brick earns the flip.

The "5 of 4" was the data model running ahead of the wiring for the most expensive field on the surface — money. The churn model isn't arbitrary: it falls out of two true things — approval is the only threshold at which a seat was ever really consumed, and the deterrent is economic not detective. The cost follows the cut, so gaming costs more than paying, and the system never has to judge intent. T3 owns every number; T4 honours them across one seam, both directions.


ADR-059 — One canonical email validator: single home, structural-only, server-side truth with a client mirror; all other email checks are defects

Status: Accepted (flipped from Proposed 2026-06-26 at the two-surface walk — see Proving brick) Date: 2026-06-26

Relates to: foundation §12 (the form/action-feedback behaviour family this email rule extends — amber summoned-on-denial), ADR-058 (the single-predicate pattern — "one place answers the question; the copies can no longer drift"), ADR-046 (the touchpoint-registry-as-forcing-function pattern — "a hidden hard-coded send cannot persist"; here the owed-surfaces inventory is the equivalent forcing function), ADR-034 (deliverability/billing fail-closed downstream), EMAIL-VALIDATION-SUBSTRATE-RECON-01-findings.md (the read-the-bytes recon), EMAIL-VALIDATION-CANONICAL-01 (the brief that builds and proves this).

The decision needed. A malformed address (admin@ucca.colle — no real TLD) submitted cleanly through the team-invite window with zero validation, reserved a seat, and was hidden by truncated roster display. The recon found the deeper shape: no shared email validator anywhere, three duplicated weak regexes (^[^\s@]+@[^\s@]+\.[^\s@]+$) that would each have passed .colle too, and no server-side validation seam on the invite path at all (inviteMember did a presence check then wrote straight to portal_invites). Email inputs appear across ~9 surfaces in 3 runtimes and growing. The weak regex got copied three times because there was no canon saying don't.

The decision. There is exactly one canonical email validator in this system, and any other email check is a defect.

  • One validator, one home. @rtopacks/validation (packages/validation) — a zero-dependency module importable by every runtime (workspace, admin, internal-api worker — the worker can import it because it carries no React/DOM). The client field mirror lives in @rtopacks/ui, which re-exports the same function (not a second validator). Server-side is the truth; the client mirror is summoned-on-denial UX only.
  • Structural-only, pragmatic subset. It validates shape, not reality: one @, non-empty local/domain, domain has ≥1 dot with no misplaced dots, alphabetic TLD ≥2 chars, no whitespace, pragmatic character set; it rejects the exotic-but-legal RFC-5322 forms real providers don't accept (quoted local parts, IP-literal/bracketed domains, comments). Returns a normalised email (trimmed, domain lowercased; local-part case preserved) on success, a human reason on failure. Callers flow the normalised value into storage + dedup and keep their dedup comparison case-insensitive (so Admin@x vs admin@x still collapse).
  • NO known-TLD list, NO DNS/MX/network check (decided explicitly, recorded so it can't creep back). admin@ucca.colle is well-formed and passescolle is simply not a real TLD; it dies downstream when the magic-link email never arrives, which is the real deliverability gate and already exists. A known-TLD list carries standing maintenance debt and a false-block risk that, in a compliance gate, is a worse failure than the leak it prevents, and wouldn't catch the larger typo class (gmial.com) anyway. The held-seat-by-plausible-garbage concern is solved by invite-expiry (banked separately), not by loading deliverability onto a format check.
  • The one-home forbidding rule. Any email check outside @rtopacks/validation is a defect to be deleted — same shape as ADR-058's single seat predicate. A stray future copy isn't a style choice to wave through; it's a canon violation. The next person touching anything email-shaped reads this and cannot not-know.

Owed-surfaces inventory — EMPTY ✓ (EMAIL-VALIDATION-PROPAGATION-01, 2026-06-26). Every email surface is wired to the one validator and all 3 weak regexes are deleted (none commented). Move 1 (4f5f7337) wired the 6 safe surfaces + gave the admin app its first @rtopacks/validation file: dep; Move 2 wired the two logins and replaced the 3 weak regexes with validateEmail (deletion, not supplement). Both moves walked to terminal effect under Tim's eyes — admin login + workspace login accept a valid address (no lockout) and reject malformed; the allowlist conditional proven (domain accepted, email-malformed refused). Enforcement is now system-wide. The forcing function reached zero; the historical inventory is retained below for lineage.

Historical inventory (now all closed): - Wired + proven (the two proving surfaces): (1) inviteMember server gate (workers/internal-api/src/orgs.ts) — fail-closed, refuses before the uniqueness reads + INSERT, so a malformed write reserves no seat; (2) entry RecordSection client check (apps/workspace, entry/page.tsx + RecordSection.tsx) — summoned-on-denial amber per §12. - Owed (~7): workspace login (apps/workspace/app/auth/page.tsx); workspace settings/org corrections (EditableField ×2); the entry server backstop (/api/entry/person save — the client is wired, the server is not); admin login (apps/admin/app/auth/page.tsx); admin add-administrator; admin create-org (provisionOrg, body.admin_email); admin add-user; admin access-allowlist. - Owed for deletion (replace, not supplement — they'd pass .colle too): the 3 weak regexes in apps/workspace/app/api/auth/magic-link, apps/admin/app/api/auth/magic-link, apps/admin/app/api/admin/administrators. - Done = owed-list empty + the 3 weak regexes deleted. — ACHIEVED 2026-06-26 ✓

Interim-state note — now resolved. When ADR-059 was filed, the rule was canonical but enforcement was not yet system-wide (only the 2 proving surfaces were wired). As of EMAIL-VALIDATION-PROPAGATION-01 (2026-06-26) the owed-list is empty: every email surface routes through @rtopacks/validation and the 3 weak regexes are gone. "Every email field is guarded" is now true, not aspirational. (The note is kept as lineage — it correctly flagged the gap that the propagation brief then closed.)

Proving brick / status. Proposed — agreed and built, but the rule is proven only when the two surfaces are walked to terminal effect under Tim's eyes: a malformed invite (admin@ucca) refused into the amber message and reserving no seat, a valid invite accepted, and the entry email field refusing malformed / accepting valid. Per ADR-STATUS-LIFECYCLE this flips to Accepted at that walk, in a brick — building the module is not proof; the refused-malformed + accepted-valid walk is. FLIPPED → Accepted 2026-06-26: the invite gate was walked to terminal effect under Tim's eyes — a malformed address refused into the amber message with the seat held at 2 (no reservation on a malformed write), and a valid address accepted with the seat moving to 3. Both arms proven. Propagation (the owed-list + the 3 deletions) is gated on this walk and may run in this brief's later moves or as a fast follow (Tim's call). The owed-list remained the live forcing function until EMAIL-VALIDATION-PROPAGATION-01 (2026-06-26) emptied it to zero — all surfaces wired, all 3 weak regexes deleted, both moves walked (incl. the deliberate login walk: valid accepted, no lockout). The brief is done; enforcement is system-wide.

The module is the home, this ADR is the doorstop, the owed-list is the forcing function — so the door cannot close on a forgotten room. The weak regex got copied three times because nothing said don't; now something does.


ADR-060 — Seat mutations serialise through a per-org Durable Object; the count stays derived from D1

Status: Accepted (flipped from Proposed 2026-06-27 at the concurrent-write walk — see Proving brick + Amendment) Date: 2026-06-27 (Accepted 2026-06-27) Relates to: ADR-058 (Accepted — seat enforcement + churn; this ADR completes commitment #2, "reserve-on-invite closes the over-issue race", which closed the invite over-issue but NOT the concurrent-write class of the same race), ADR-020 (T4A is the countable seat), ADR-021 / ADR-032 / ADR-038 (seat_limit lives in ops-db, read via the internal-api seam per OPS-SURFACE-RULE; the seat count derives from tier_grants + portal_invites in identity-db), ADR-057 (revoke sets status='revoked'), HARD SEPARATION RULE (identity-db and ops-db are distinct substrates; this ADR does not collapse them), SEAT-COUNT-ATOMICITY-01 (the standing tripwire this resolves), SEAT-ATOMICITY-RECON-01-findings (the read-only recon that proved the naive lock is dead and scoped this decision).


The decision needed. The live seat count is a derived predicate spanning two D1 databases: COUNT(*) non-revoked T4A grants + COUNT(*) outstanding seat-consuming invites (both identity-db) versus orgs.seat_limit (ops-db). The recon proved three things follow from this:

  1. There is no count column — so a single atomic UPDATE … WHERE count < limit is structurally impossible.
  2. The predicate spans two databases, so no single D1 statement can hold it even in principle.
  3. D1 gives no cross-request serializable isolation and no SELECT … FOR UPDATE.

The consequence is a TOCTOU (time-of-check-to-time-of-use) race: every seat claimer reads the count, finds room, then writes — and two concurrent claimers can both pass the check before either writes, breaching the cap (the observed 5-of-4). The recon also found this is a family of races, not one: the cap race, sibling release-vs-claim races (a revoke and a claim interleaving), and a replica-lag-sensitive FIFO.

ADR-058 commitment #2 stated reserve-on-invite "closes the over-issue race." That was true for the invite over-issue (N concurrent invites against the cap). It did not close the concurrent-write class — that window stayed open across all claimers, and promoteMember was unguarded entirely (since fixed, 92065241, but still read-then-act). This ADR names and closes the class ADR-058 left open. It is a completion of ADR-058, not a reversal.

The naive answer — "add a lock / wrap it in a transaction" — is dead on arrival: there is no statement, and no database, in which the lock could live. The question is therefore architectural: where does the serialisation that D1 cannot provide actually come from?


The decision. Two commitments.

1. All seat mutations for an org serialise through one Durable Object per org.

A Durable Object is single-threaded per instance: requests to the same DO are processed one at a time, in order, with no concurrency. Routing every seat-mutating operation for a given org through that org's DO means two concurrent claimers cannot both pass the check-then-act — they queue, the first claims, the second re-reads the now-updated count and is refused at the cap. This is the cross-request ordering guarantee D1 does not give; the DO gives it by construction.

The DO is the single door for the whole race family, not just the cap race. Because every mutation — claim, release, promote, reactivate — passes through the same serialisation point, the sibling release-vs-claim races and the FIFO ordering are resolved by the same mechanism, not by separate per-race patches.

The seat-mutating writers that must route through the DO (confirmed against live bytes, 2026-06-27). Claimers (raise used): inviteMember and reinstateInvite (reserve a seat on invite — reinstateInvite re-reserves a revoked invite back to pending), promoteMember (T4B→T4A grant), and the updateMember reactivate arm (clears revoked_at → re-consumes a seat, cooldown-gated). Releasers (lower used): revokeInvite, deactivateMember. Convert-pair (claimed net-zero): mintGrantsForInvite mints the grant as the reserved invite leaves pending|returned — it is not independently cap-guarded and relies entirely on the reservation holding atomically against the conversion; the build brief must verify this convert-and-mint pair is not itself a mini-TOCTOU window when interleaved with another claimer. State-movers (claimed net-zero): approveOnboarding, returnOnboarding, submitAttestation, applyCooldownLockOnConsumption move invite/grant state without changing used — but "net-zero" is a claim about the count, not about ordering, so the build brief determines whether each still needs DO serialisation to protect the FIFO and the convert-pairing. This ADR commits the principle that no seat mutation bypasses the DO; the build brief carries the final per-writer routing decision against live bytes. (Excluded: dev-teardown DELETEs — dev-only, tree-shaken from prod.)

2. The authoritative seat count stays derived from D1. The DO serialises writes; it does not own the count.

The DO is a serialisation gate, not a second source of truth. Inside the DO's single-threaded critical section, the handler reads the count from D1 (the existing seatConsumption predicate, unchanged), checks it against seat_limit, and writes the grant/invite — all without another writer able to interleave, because the DO guarantees that. D1 remains the one place the count lives.

This is the load-bearing sub-decision, taken deliberately:

  • One source of truth. Moving the count into DO state would create a second count to reconcile against D1 — a new class of drift, exactly the kind ADR-058 commitment #1 spent effort collapsing. Keeping the count in D1 keeps the count-drift class closed.
  • Stale reads are safe; only concurrent writes breach. A read off a lagging replica can show a slightly old count — but a stale read never breaches the cap. Only two writes slipping past one check does, and that is precisely what the DO closes. The DO does not need to own reads to make writes safe.
  • The serialisation is on the write path, where the race lives. Reads outside the DO (the badge, the roster, the count envelope) continue to derive from D1 as they do today, unchanged.

Why this shape (rejected alternatives).

  • Add a lock / wrap in a transaction. Rejected — proven impossible by the recon: no count column, cross-DB predicate, no cross-request serializable guard in D1. There is no statement and no database for the lock to live in.
  • Single-DB restructure (move seat_limit into identity-db so an atomic single-statement check-and-claim becomes possible). Rejected as the primary path — it moves billing/entitlement substrate (seat_limit, ops-db) across the HARD SEPARATION boundary to make a SQL statement convenient, letting an implementation detail dictate a substrate boundary drawn on purpose. It also only cleanly fixes the single-INSERT-shaped cap race; the sibling release-vs-claim races are not all single-statement-shaped and would still need their own handling. A boundary move with real blast radius (every seat_limit reader) that solves less of the problem. Kept on record as a fallback if the DO read-path proves unworkable, but not chosen.
  • Move the authoritative count into the DO's own state. Rejected — introduces a second source of truth to keep honest against D1, reopening the count-drift class. The DO serialises writes; D1 owns the count.
  • Optimistic-retry on a derived count (write, detect breach, roll back, retry). Rejected — without a serialisation point the detect-and-rollback still races, and a derived cross-DB count has no clean compare-and-swap to retry against. Adds complexity without the guarantee.

Consequences.

  • A new architectural layer — a Durable Object per org — enters the system. Lifecycle, routing (org → DO instance), and the internal-api wiring to send seat mutations through it are new build surface, scoped in the build brief, not here.
  • HARD SEPARATION is preserved: identity-db and ops-db stay distinct; seat_limit stays in ops-db, read via the existing OPS-SURFACE-RULE seam. The DO sits in front of the writers; it does not collapse databases.
  • The whole race family closes through one door: the cap race, the sibling release-vs-claim races, and the replica-lag FIFO are all resolved by serialisation rather than per-race patches.
  • The seatConsumption predicate is unchanged — the count still derives from tier_grants + portal_invites vs seat_limit. The DO changes when the check-and-write runs (serialised), not what it computes.
  • Reads outside the DO (badge, roster, seats envelope) are untouched and continue to derive from D1.
  • Invite-expiry (59087426) remains cleanly separable — expired is a plain non-pending row that drops from the count like a revoke, with no predicate term; the cron release path does not need to route through the DO (it is a sweep, not a claim against the cap). The build brief confirms whether the expiry sweep's writes need DO ordering or are safe as a global non-claiming UPDATE; the recon's read suggests the latter, to be verified.
  • This ADR is the decision. The build brief (BRIEF-SEAT-DO-SERIALISATION-01 or similar) comes after acceptance, one drip, and carries the writer enumeration against live bytes, the DO lifecycle/routing design, and the read-path wiring.

Proving brick / status. Proposed — agreed but unproven in code. Per ADR-STATUS-LIFECYCLE this flips to Accepted only when the race is exercised to terminal effect on dev by a controlled concurrent-write test: fire two simultaneous seat-claim requests against an org at cap-minus-one, confirm exactly one succeeds and the count holds at the cap. A click-through walk does not prove a race closed — only concurrent writes do, and concurrent writes are literally how the 5-of-4 was produced. Building the DO and routing the writers is construction, not proof; only driving two concurrent claims through to a held count is. FLIPPED → Accepted 2026-06-27 at the concurrent-write walk (BRIEF-SEAT-DO-SERIALISATION-01 + BRIEF-SEAT-DO-CONSISTENCY-01): two member invites fired together at cap-minus-one, multi-run — exactly one wins, the count holds at the cap (never 5-of-4), the FIFO cooldown lock stamps exactly once on the oldest free-deactivated (coherent with serial execution), and the winner alternated across runs (genuine contention, DO-arbitrated). The control sequential case still refuses the second claim. The race is closed; the proving brick earns the flip.

Amendment (2026-06-27, post-walk) — the portable DO lesson this arc cost two misdiagnoses to learn. A Durable Object's input gate serialises only its own STORAGE operations. A critical section built on external I/O (D1, fetch, KV) is NOT serialised by routing through a DO — the input gate stays open across the await and concurrent requests interleave. Serialising a D1-based critical section requires blockConcurrencyWhile(). The first DO build (deployed dev 896ab49c) routed every seat write through the DO but did not wrap the D1 critical section, and reproduced 5-of-4 by interleave — not replica lag (read replication is disabled on identity-db, so the cap-check read hit primary and was never stale; the breach was missing serialisation, full stop). Resolved (BRIEF-SEAT-DO-CONSISTENCY-01) by wrapping each method's cap-check-and-write in blockConcurrencyWhile; 896ab49c is superseded by 5e39b6a5. The count stays single-source in D1 — this does NOT move toward the rejected DO-owned-count (Option B). The next DO build on this codebase hits this note before it assumes "route through a DO ⇒ serialised." (An interim "stale decision-read / read-your-writes" framing was also wrong for the same reason — the reads were never stale.)


The naive lock was never available — the count is derived, cross-database, and D1 offers no cross-request serialization. The fix is not a lock inside the data but a serialisation gate in front of the writers: a per-org Durable Object turns concurrent seat mutations into an ordered queue, so the check-then-act window the recon found can no longer be straddled. The count stays where it always lived — in D1 — because a stale read never breaches a cap; only a double write does, and that is exactly the door the DO shuts. The substrate boundary that single-DB would have crossed stays drawn: billing stays with billing, the gate sits in front.


ADR-061 — People person-record reads are role-gated and field-redacted; the read surface is not view-everything

Status: Accepted (proven at the permission walk — see Proving brick) Date: 2026-06-27 Relates to: PC-SPEC-01 §10.2/§10.3 (the source rule — T4B references/sees no other person; a T4A colleague read is redacted), ADR-040 (pc_persons.user_id ↔ auth identity — the self-read join), ADR-022 (the general "T4 view-everything within the client boundary" assumption this REFINES for the People surface), PEOPLE-SURFACE-ROLE-OPEN-01 (the tripwire this closes), SIGNAL-LANGUAGE-01 (hidden ≠ absent).

The gap. The People person-record read paths (the workspace-app GET handlers for person detail + compliance + cpd) were authenticated + org-scoped but carried no role or field gate. Any authenticated org member — including a T4B, whose entire spec scope (§10.3) is "own record only, references no other person" — could read any colleague's full compliance record, including personal and government-identifier fields (residential address, DOB, WWCC number, USI). The write paths were already T4-gated; the read side was not. The general "T4 admins view everything within the client boundary" assumption (ADR-022) was being applied to every member, not just T4 — and even for admins it over-shared a colleague's most personal fields to non-admin members.

The decision. The read-side People access rule, keyed off the caller's discrete grant set (held_tiers, not tiertier reports only the highest grant, so a T4A+T4B holder reads tier='T4A'): - Self (pc_persons.user_id === auth.user_id, ADR-040) → FULL, no redaction. A T4B reading themselves gets everything — that is their whole purpose. - T4 (admin) → FULL, no redaction. - T4A (colleague) → FULL minus five fields: residential_address, date_of_birth, middle_name, wwcc_number, usi. - Else (T4B-on-others, T3-only, empty/unknown) → 403, fail-closed.

Hidden ≠ absent (SIGNAL-LANGUAGE-01). The five fields are dropped from the projection (true redaction — they never ride the wire on a colleague read) and the handler emits redacted: string[]; the surface renders those as "Withheld", never "Missing"/blank. The WWCC/USI status fields (wwcc_verified/expiry/required/issuing_state) stay visible — only the number is withheld; a withheld number must never read as a compliance gap.

Scope. Field redaction bites only the detail handler (the only read returning the five columns); compliance (computed status/labels) and cpd carry none → role gate only (deny-or-allow). The supervisor display-name lookup was org-scoped in the same pass (a cross-org name-leak gap). Writes (PATCH/POST) stay T4-only (unchanged). T3-only operator sessions are denied by this rule as written — if operator viewing of client person-records is needed, it is a deliberate follow-on, not a silent allow.

Consequences. - The People read surface is no longer view-everything. ADR-022's "T4 view-everything within the client boundary" is refined: it holds for T4 admins, not for every member, and even then a member's most personal fields are colleague-hidden. - held_tiers is now surfaced on WorkspaceAuth (was session-only) so any role gate can distinguish held grants from the highest grant. - The gate is one shared helper (person-access.ts) applied per handler — one chokepoint each.

Proving brick / status. Accepted — proven at the permission walk (BRIEF-PEOPLE-ROLE-OPEN-FIX-01): T4A reads a colleague (200, five fields absent, redacted lists them, surface shows "Withheld" not "Missing"); T4B reads a colleague (403); T4B reads own (200, full, all five present — the load-bearing exception); T4 reads anyone (200, full, nothing redacted); cross-org (404). PC-SPEC-01 §10.2 was the source rule.


ADR-062 — The person-role vocabulary: four populations, single-valued; deliver-vs-assess is the engine's verdict, not a role tag

Status: Accepted (proven at the role-corrective walk — see Proving brick) Date: 2026-06-29 (decided by Tim 2026-06-28) Relates to: ADR-040 (pc_persons — the record this role sits on), ADR-061 (the read gate keys off held_tiers, the access axis — this ADR governs the role axis; the two are deliberately separate), ADR-051 (status colour is derived, never stored — the same "compute on read" discipline), the spec §3 (the canDeliver engine that owns deliver-vs-assess) and §10.6 (multi-hat, now named-future — see the spec redline this ADR triggers), BRIEF-PEOPLE-ROLE-CORRECTIVE-01 (the brick).

The gap. pc_persons.role was a convention-only vocabulary: five duplicated ROLE_LABELS maps across the surface, no shared type, no write-boundary guard, and no CHECK on the column — so any string could land in it from a raw write. The stored values carried two conventions at once (trainer_assessor lowercase from the form; Management Title-case from a legacy seed), and that casing split caused a latent compliance mis-flag (a management person read as a teaching person missing trainer credentials, because one consumer compared role without case-folding). The vocabulary also still carried trainer and assessor as standalone values — a three-way trainer / assessor / trainer-assessor split.

The decision. The canonical person-role vocabulary is four populations, all lowercase_snake:

  1. trainer_assessor — the teaching population; the only role carrying a full four-dimension compliance record.
  2. expert — industry specialist, legitimacy is expertise-under-direction (not a T&A credential); a supervision-shaped record (pc_expert_engagements).
  3. support_staff — name + position only; no compliance dimensions.
  4. management — the accountable party; a wafer-thin record (name / role / contact).

trainer and assessor standalone are removed. The load-bearing reason: whether a person delivers, assesses, or does both is not a fact about the person — it is a verdict about an assignment. The same trainer can be deliver-only for one unit and deliver-and-assess for another, because the answer is computed from the credentials they hold against what that unit's activity requires. That computation is the engine's job (canDeliver, spec §3), and §3.2 already forbids the engine from reading role at all. Tagging deliver-vs-assess onto the person was presentation doing the engine's job — the corrective takes that job back. One teaching population, trainer_assessor; the deliver/assess distinction lives only in the per-assignment verdict, never as a standing tag. (This is the role axis. The clean separation worth holding: role describes the person/record; the verdict describes the assignment; the tier/grant describes access. Three axes, never collapsed.)

Single-valued now; multi-hat is named-future. role holds exactly one value today. The spec's §10.6 frames multi-hat (a person holding a set of role tags) as the target; that is re-weighted to named-future, not current state — see the consequence below and the spec redline. Single-valued is the honest current build; making role multi-valued is a backlogged data-model change.

The guard is app-level (deliberately, given no DB CHECK). Every writer that accepts body.role (persons POST, persons/[id] PATCH, entry/person UPDATE) validates against the four (case-folding a valid-but-miscased value, e.g. Managementmanagement) and rejects a non-canonical value with a 400 before write. The four values, the type, the labels, and the non-teaching subset are single-sourced in apps/workspace/lib/roles.ts — the five duplicated label maps reconcile to it. The column has no constraint; the boundary does. (Worth knowing when reasoning about what could land in that column from a raw write: nothing — the writers won't pass it.)

The banding rationale — depth-of-record follows depth-of-obligation (the why behind the four, especially management). Each population appears in People, rendered at the depth its obligation warrants: trainer_assessor carries the heaviest obligation → the full record; expert a supervision-shaped one; support_staff the lightest (name + position); management an accountability obligation, not a teaching one → wafer-thin. The sharp point is management: it is tempting to think a non-teaching admin doesn't belong in a compliance module at all. But accountability to ASQA sits with management — when an RTO fails an audit, the managing agent's name is on the line. The accountable party cannot be invisible in the compliance record. So management always appears, wafer-thin — never absent. This closes the spec's prior open question (§16 "appear-as-name vs absent"); depth-follows-obligation resolves it to thin, not absent. (Round-2 note, recorded so it isn't relitigated: the banding is structural grouping, not colour — "colour individuates, structure groups"; colour in People is reserved for status, never for population.)

Consequences. - One canonical role vocabulary, single-sourced; the surface can no longer drift a sixth value or a casing variant into the column. - The latent mis-flag is dead at both layers: the consumer now case-folds (code), and the stored Title-case residue was normalised (data). It was latent on prod, never live. - Spec redline owed (triggered by this ADR): people-02-spec.md §3.2's role-value parenthetical still lists the old five; §10.6 still frames multi-hat as the corrected target; §16's management "appear-as-name vs absent" is now closed. Redlined in the same canon-closure pass that files this ADR. - Multi-hat re-weighted up (named-future, not built): in the micro/small-RTO segment — the reachable early market, and the shape UCCA itself runs as — the median operator wears every hat (director and trainer/assessor), so multi-hat is the dominant shape there, not the edge. Tim and Jimmy are the first two live specimens (stored single-hat trainer_assessor, genuinely {trainer_assessor, management}; their management hat rides their tier, not their role). When multi-hat lands, it is the record that learns to hold two hats — access already does.

Proving brick / status. Accepted — shipped + walked at BRIEF-PEOPLE-ROLE-CORRECTIVE-01 (a6b078c7): the four-value source + the three write guards + the consumer case-fold landed; the data was normalised on both envs (0 stragglers, every stored role ∈ the four); and the dev walk confirmed a management person (the Aidan-Jones canonical-mint fixture) renders grey N/A while a trainer_assessor reads honestly-red where credentials are absent — the mis-flag gone, the populations distinct.


ADR-063 — The People lifecycle gate: appearance is grant-status, resolved by an app-level cross-db join; People now depends on internal-api

Status: Accepted (proven at the Round-1 triangle walk — see Proving brick) Date: 2026-06-29 (built in Round 1, 2026-06-28) Relates to: ADR-040 & ADR-041 (the HARD SEPARATION this honours — pc_persons in workspace-db, grants in identity-db, no person-core DB, no cross-db SQL join), ADR-038 (the canonical grant record the gate reads), ADR-061 (the redaction gate, keyed on held_tiers — this ADR is the appearance gate, keyed on grant status; the two axes are deliberately separate), ADR-023 (deactivate-not-delete — why revoked stays), BRIEF-PEOPLE-ROUND-1-01 §C (the brick).

The gap. The People register was a flat SELECT * FROM pc_persons WHERE org_id = ? — every person-row for the org, ungated by whether the person actually holds a live membership. There was no lifecycle relationship between appearing in People and holding a grant. (The earlier capture claimed the gate was already live and reused — the Round-1 recon disproved that; it was built fresh in §C.)

The decision. A person appears in People iff their pc_persons.user_id matches a grant member, gated on grant status, not tier: - active (non-revoked grant) → appears, at any membership_typeadmin_only (pure-T4) included. Tier does not gate appearance. - revoked (grant revoked_at set) → stays, marked "Revoked {date}" — revocation is audit evidence (ADR-023); a five-yearly audit may need a trainer who delivered then left. - no match (pending invite / no grant) → hidden (a pending person is visible in Teams, not People).

Tier drives redaction, not appearance. Whether you appear is a status question (do you hold a live grant); what fields a viewer sees once you appear is the tier question (ADR-061). Keeping these two axes separate is the load-bearing distinction — conflating them (e.g. "only T4A appears") was the over-specified earlier framing the build corrected.

The join is app-level, never SQL — and that is what earns this its own ADR. pc_persons (workspace-db) and tier_grants (identity-db) never join in SQL (HARD SEPARATION, ADR-040/041). The register reads pc_persons on workspace-db, calls internal-api getMembers over the INTERNAL_API service binding, and joins the grant set in worker code. The consequence is a new hard runtime dependency: People → internal-api. The register can no longer render from workspace-db alone. It fails closed — if membership can't be resolved, the endpoint returns 502, never an unfiltered or silently-empty list (a wrong People list is worse than an honest error). That new cross-service dependency — not the appearance rule alone — is why this is a standalone ADR rather than an amendment to the ADR-040/041 seams: it is a new commitment about how People is allowed to fail.

Why not relax the separation and join in SQL. A cross-db join would be simpler to write and would remove the runtime dependency — and it is exactly what ADR-040/041 forbid, because it re-entangles the compliance substrate with the access substrate. The app-level join keeps the two databases independent at the cost of one service hop; the hop, and its fail-closed behaviour, are the accepted price.

Consequences. - People is grant-gated: the enter → approve → appears triangle (and revoke → stays-marked) is real and load-bearing, not cosmetic. - People has a runtime dependency on internal-api; monitoring/availability of People now includes that seam. Fail-closed (502) is the contract. - After the schema cleanup there is no NULL-user_id pc_persons row, so the join is total; a NULL is treated as a regression (logged + dropped), not a state to handle.

Proving brick / status. Accepted — proven at the Round-1 triangle walk under Tim's eyes (d6433fdc): a person with no grant did not appear; approving them (grant minted) made them appear regardless of tier; revoking left them in place marked "Revoked {date}". The admin_only-appears case was confirmed on the canonical-mint fixtures (BRIEF-PEOPLE-ROLE-CORRECTIVE-01 §4 walk).

ADR-064 — Discharging-event ledgers live in tile-domain tables, not a shared regulated-events store

Status: Accepted Date: 2026-07-10 (LLND-BUILD-01, Gate 3) Relates to: ADR-029 (the uniform activity stream — the shared rtopacks-audit-* substrate this ADR draws a wall against), ADR-045 (the audit-emission discipline that governs cross-posts to that stream), ADR-040/041 (the HARD SEPARATION whose logic this extends from access-vs-compliance to per-tile compliance), tas-02 §14.3 (the forward-referenced decision this resolves), llnd-build-rulings-01 (the ratified rulings that made it load-bearing).

Context. A regulated tile (LLND, and next RPL/TAS) produces discharging events — the record that a compliance obligation was met: an advice issued and delivered, a currency marker set, a divergence signed. The question the first tile build cannot defer: does that ledger live in a shared regulated-events store across all tiles, or in each tile's own Peel store? tas-02 §14.3 forward-referenced the decision; LLND is the first build to hit it, so it is settled here per CANONICAL-DECISION DISCIPLINE.

The decision. Each regulated tile's discharging-events ledger is a table in that tile's own Peel storellnd_event in rtopacks-llnd, and by the same rule rpl_event/tas_event in their domains when built. There is no shared regulated-events database. This binds LLND, RPL, and TAS.

Rationale. The ledger is regulated tile-domain data. Events reference tile fields (field_ref) and the clause traces live on those fields — the clause is never copied onto the event (NO ORPHAN GRAINS), so the ledger only means anything alongside the domain rows it points at. Under HARD SEPARATION (ADR-040/041) the compliance substrate travels with its domain; a shared cross-tile events store would re-entangle three regulated domains in one table for no query that matters (the cross-tile "what happened" question is the activity question, already answered by ADR-029's uniform stream). Keeping the ledger in-domain also means a tile's compliance record is complete within its own store — no second store to join for defensibility.

The wall against the activity stream. The shared rtopacks-audit-* substrate (ADR-029's uniform activity stream) remains platform activity attribution — who-did-what-when across the machine. A tile ledger MAY cross-post a non-authoritative activity row to that stream under the ADR-045 emission discipline, but that row is never the compliance record; the authoritative discharging-event lives in the tile's *_event table. The two walls stay separate permanently: activity attribution in the shared stream, the compliance ledger in-domain.

Consequences. - Each regulated tile build creates its own *_event table as part of its substrate; there is no shared regulated-events store to provision or migrate into. - The audit stream (ADR-029) and the per-tile compliance ledger are permanently separate; a cross-post is additive attribution, never the record of discharge. - RPL and TAS inherit this without re-deciding — their event ledgers are rpl_event/tas_event in their own Peel stores by this ADR, not a fresh call at their build. - Composes with ADR-028/029/045 (observability + activity substrate) and ADR-040/041 (HARD SEPARATION).

Proving. First expressed as llnd_event in the LLND store schema (LLND-BUILD-01 Gate 3) — 7 launch event types, field_ref pointing at tile fields with no clause copied onto the event.

ADR-065 — The launch session anchor for a conducted LLND result is a disclosed interaction record, not audiovisual capture

Status: Accepted Date: 2026-07-10 (LLND-CONDUCT-01) Relates to: ADR-064 (the tile-domain discharging-event ledger this sits beside), the LLND-CONDUCT-01 ruling batch (the conduct-commissioning rulings that made it load-bearing), llnd-02 §4.1 / §9.6 (the redlined spec surfaces), llnd-00 (the biometric-adjacent posture this scopes to AV).

Context. A conducted LLND result needs an authenticity anchor — the byte record that lets an advice challenged at audit replay from evidence, not from a score. The model reached for a "session recording" as that anchor. Conduct commissioning forces the concrete call: what does the launch tile actually capture, and does it request browser media permission? The choice is load-bearing for privacy posture, consent design, and the biometric-adjacent gate.

The decision. At launch, conduct mode seals a disclosed interaction record — an event stream (item presented / answered timestamps, focus / blur, response revisions) — as the authenticity anchor, digest-sealed and immutable in rtopacks-llnd-evidence. No browser media permission is ever requested at launch: no webcam, microphone, or screen capture. Audit defensibility rests on the sealed response set + the interaction record + the sealed instrument version — the same replay guarantee, from non-AV bytes.

Rationale. The interaction record carries the authenticity signal a pre-enrolment screen needs (was the session actually worked, in the pattern of a genuine attempt) without ever crossing into biometric capture. Not requesting media permission means the tile never holds a candidate's face or voice at launch, so the tightest consent-and-retention gate does not attach to the launch anchor at all — it attaches only to AV, and only when AV is built. Defensibility is unharmed: the sealed response set replays what was answered, the interaction record replays how the session ran, and the sealed instrument version replays against what frame — three non-AV anchors, one replay.

AV is a day-two increment. Audiovisual capture arrives day-two together with the oral-communication AV task (llnd-02 §4.9), under one consent framework. AV is biometric-adjacent — its own consent, its own retention posture, and a non-AV alternative where the configured bar allows. The biometric-adjacent language scopes to AV, not to the interaction record.

Consequences. - The LLND launch surface requests no getUserMedia / media permission; the conducted-result anchor is the interaction record + response set only. - The biometric-adjacent posture (llnd-00, the sealing-note recording language) binds to day-two AV, not to the launch anchor — the two must not be conflated in copy or config. - The evidence vault holds response sets and interaction records for conducted digital results at launch; AV recordings are a day-two artefact class (llnd-evidence-sealing.md). - RPL and TAS, when they reach conduct, inherit the disclosed-non-AV-anchor default unless a clause compels otherwise; AV remains an opt-in increment, never a launch assumption.

ADR-066 — Candidate pre-conduct product framing resolves through the internal-api seam, not the intake plane

Status: Accepted Date: 2026-07-11 (LLND-CANDIDATE-FRAMING-01) Relates to: ADR-065 (the conduct seam this extends), ADR-030/031 (internal-api isolation, the service-binding seam), SHARED-SPINE-STORES-01 (the intake-plane neutrality seam), the CONDUCT-01 G2v2 single-locus refusal-check WATCH (re-examined here), llnd-02 §2.2 (llnd_review.target_tga_id).

Context. The candidate's pre-conduct landing (GET /s/:token on rtopacks-intake-candidate-surface) must name the product the candidate is being reviewed for. The product code (target_tga_id) lives on llnd_review in LLND_DB — a plane the candidate surface is prohibited from binding (NEVER bind LLND_DB, OPS_DB, send_email). The deployed readProductFraming was broken three ways (queried a non-existent qualifications table, keyed on the tile code consuming_tile, and the intake plane holds no product code at all), so it always returned product: null. Three paths could reach the product to the landing: (a) internal-api resolves it and returns it over the existing service-binding seam; (b) denormalise product framing onto the intake invitation/engagement; (c) a formal canon exception carrying product data in the intake plane.

The decision. Path (a′), the improved form of (a). A new read-only internal-api route POST /llnd/framing/session (service-binding only, allowlisted to the intake-candidate-worker source) resolves the review's product identity from Pith and returns it. It reuses the shipped resolveProduct helper (internal-api binds RTOPACKS_DB = rto-nrt-db, the Pith with tga_training_components, in both envs) and returns the trio {code, title, component_type} — product-catalogue identity only, never levels/results/config/advice. The candidate surface calls it with review_id (= engagement.tile_ledger_ref, which it already holds) and displays the result, failing open to the static offer on any error. Paths (b) and (c) are rejected: (b) writes tile-domain product data onto the intake engagement — a direct violation of its "NEUTRALITY SEAM: NO tile-domain payload here" invariant (SHARED-SPINE-STORES-01) and a product-truth denormalisation; (c) is an unnecessary canon move.

Rationale. internal-api already resolves and egresses product on the conduct-session projection, so the read-back class of this data is established practice on this seam — framing adds no new class, only a second read-only prefix. Resolving at internal-api (which reads prod Pith in both envs) means dev titles resolve too — it fixes the F2 dev-null-title limitation as a side effect, and the candidate surface no longer reads NRT_DB for framing at all. The intake plane stays product-neutral: no DDL, no denormalisation, no new store dependency.

The refusal-check WATCH (CONDUCT-01 G2v2) — re-examined, not retired. The intake-candidate-worker source's reachable route-set is defined in a single locus — the SOURCE_ROUTE_ALLOWLIST predicate in internal-api/src/index.ts. This decision edits that one predicate (startsWith("/llnd/conduct/")… || startsWith("/llnd/framing/")); the single-locus property is intact. The anti-tunnel guard (public surface must not reach general LLND state/results/config) is not breached: framing returns public TGA catalogue identity only, the same data class already egressing on conduct. Refusal semantics are unchanged (a non-allowlisted path from this source still returns 403 "source not permitted on this route"). The WATCH re-fires at the next prefix addition.

Consequences. - internal-api gains one read-only route (/llnd/framing/session); no writes, no new bindings, no DDL, no new CF resources, no new outbound. - The candidate surface's NRT_DB binding remains but is no longer used for framing (removing it is out of scope). - Response minimalism is a standing contract on this route: product identity (code/title/component_type) only — any future field (status, results, config) requires an explicit ruling, never a silent extension. - The intake-plane neutrality seam (SHARED-SPINE-STORES-01) is preserved; product truth stays single-sourced in the LLND plane + Pith.

ADR-067 — LLN routing machinery: sealed presented-form manifest + exposure record + REM (replay-by-artefact)

Status: Accepted Date: 2026-07-11 (LLND-LLN-MACHINERY-01) Relates to: RC-L3 + RC-L3-A (the moat pair, ruled as one 2026-07-11) · RC-L10 (REM) · RC-L2 (floor/ceilings) · RC-L9 (explicit pack_ref election) · ADR-064/065 (the LLND tile-domain ledger + conduct anchor this extends) · llnd-02 §2/§8.

Context. The LLN rung needs a deterministic, defensible screening spine: two-stage testlet routing (a candidate is not marched through mismatched difficulty), a durable record of exactly what was presented (the incumbent randomises but seals nothing — the competitive gap), retake freshness (never the same form twice), and a single tamper-evident binding of the whole review. The digital rung had none of these (fixed 30-item form, no exposure, no manifest, no REM).

The decision (one coherent machinery, one ADR). 1. Routing pack schema v2 (additive). A routed pack carries a routing block per skill (locator + sealed routing_rule + three testlets × 2 equated parallel forms); flat digital v1/v2 packs carry none. An explicit schema_version discriminator (absent/1 → flat, 2 → routed) branches the conduct path once at pack load, so digital conducts byte-identically. The scorer routes deterministically (locator raw score → band → form → within-testlet count-of-correct) reading the sealed response set alone (strata wall unchanged). 2. Sealed presented-form manifest — unconditional (L3-A). Every conduct sitting seals, content-addressed to the evidence vault at {review_id}/{class}/presented_form_manifest/{sha256} (put-if-absent), the item refs presented + order, form identities, pack ref/version/digest, rendering version, and the selection inputs. Replay is by artefact, never by recomputation — the UCCA-engine property. Digital gains it too (trivially fixed-form). Referenced on llnd_result.presented_form_manifest_ref. 3. Exposure record — retake freshness (L3-A). llnd_form_exposure (append-only facts, keyed on the platform-global candidate_ref × capability_class × skill × form_id, with review_ref + exhausted + exposed_at) drives deterministic-given-history form selection: an unseen form is preferred; on exhaustion (all forms seen) the least-recently-seen is reissued and the re-exposure is loudly recorded (exhausted=1 + manifest selection_inputs), and every exhaustion is a named content-workstream trigger (cycle-and-record, ruled 2026-07-11 as an RC-L3-A amendment). Exposure rows are facts, never scoring inputs (strata discipline). The no-blocking-UNIQUE admits re-exposure facts. 4. Review Evidence Manifest (REM) — RC-L10. At COMPLETE, a mechanical canonical-JSON binding of identities + sealed frame + per-capability evidence digests (the presented-form manifest is a named member) + the discharge → sha256 → vault {review_id}/manifest/{sha256} (put-if-absent) → llnd_review.rem_digest. REM rides COMPLETE — no new event (ev8), no ledger CHECK migration (ruled 2026-07-11). PKI signatures stay deferred (ruled); the hash-manifest is the tamper-evidence.

Floor representation (RC-L2, O1 Option A, ruled). The remote-screen undistinguished Pre-L1 floor and the five LLN skills are represented in the result's levels JSON (level NULL) — exact parity with the deployed digital path (digital already stores per-competence levels JSON; the seven-state CHECK only ever policed the single level column). Seven-state value-set enforcement is at pack-level validation, documented in the llnd-02 §2 redline. No CHECK change, no cardinality change.

Conduct topology (named at build). Single session/submit with re-derive: form selection is a pure function of exposure history read at session-open (no write); submit re-resolves the route from locator responses and re-derives the same form selection (history unmutated within a sitting), scores it, then writes the exposure fact and seals the manifest. Avoids new routes/state; deterministic given no interleaving sitting for the same candidate/skill between one sitting's session and submit (a dev-walk-safe assumption; noted).

Consequences. - internal-api conduct gains routing + exposure + manifest; the capability_class='digital' hardcode is parameterised to the elected pack's class (O2). No allowlist change, no candidate-surface change, no new CF resources (audio substrate is ASSETS-01). - The workspace currency route assembles the REM at COMPLETE (best-effort; COMPLETE never stranded). - Dev-first, prod inert: schema migration equalises to prod at G5; no LLN pack is published to prod, no prod conduct exercised. - The publish tool needed no change (it seals canon(pack) bytes; routing is transparent to it). - Honoured-result REM chaining is designed (a per-evidence-row field) but its population for reuse results is day-two.

ADR-068 — Instrument asset substrate + tokened read-path-only serving

Status: Accepted Date: 2026-07-11 (LLND-LLN-ASSETS-01) Relates to: RC-L4 (sealed audio asset substrate) · ADR-067 (routing machinery; audio_ref schema hook shipped there) · ADR-030/031 (internal-api isolation, INTAKE-CLASS binding prohibition) · llnd-02 §3.2/§4.2 · the CAPTURE_R2 custody-class precedent (candidate-capture-sealing.md).

Context. LLN listening items reference audio by digest (pack audio_ref {digest, mime, duration_ms}, a schema field shipped inert by MACHINERY-01). The substrate to store, verify, and serve that media did not exist. It must be content-addressed and immutable (a published instrument is a sealed artefact), verified at publish (a pack must not seal a reference to bytes that are absent or wrong), and served to candidates without a public bucket, without signed URLs to third parties, over the same tokened session that gates the sitting — while honouring the INTAKE-CLASS BINDING PROHIBITION on the candidate surface.

The decision. 1. Custody class + bucket pair. rtopacks-llnd-instrument-assets-{dev,prod}published-served instrument content, a distinct custody class from the evidence vault and the capture store (the classes do not mix). Content-addressed keys = bare {sha256} (hex); httpMetadata.contentType carries the mime, so serving needs no pack load. Put-if-absent, immutable — an amendment is a new digest. Outside Terraform (wrangler); IaC debt on TERRAFORM-V5-RECONCILIATION-01. 2. Assembly pipeline (separate tool). tools/assemble-llnd-lln-pack.mjs — an asset-augmentation pass over hand-authored LLN packs (NOT a generalisation of the source-pinned digital assembler, whose byte-identity seal stays out of the blast radius). It content-addresses each audio file into the env bucket (put-if-absent) and stamps audio_ref onto mapped items. The tooling canonicaliser is single-sourced at tools/lib/llnd-canon.mjs (shared with the publish tool). 3. Publish-time verification (GET-and-hash). tools/publish-llnd-instrument.mjs re-GETs every audio_ref object and re-hashes it, refusing the publish (ASSET_MISSING / ASSET_DIGEST_MISMATCH, nothing inserted) on absence or mismatch. HEAD-existence would not prove the bytes; the check must not trust the write path. 4. Tokened serving — D1 ruled ADOPT with condition C1. GET /s/:token/asset/{digest} on the candidate surface binds LLND_ASSETS_R2 directly and streams (Range → 206, private, max-age=31536000, immutable, ETag; dead/revoked token → 410; unknown digest → 404). The BINDING PROHIBITION guards three planes — the LLND result/conduct plane (LLND_DB), the ops/audit plane (OPS_DB), and the outbound-send seam — and published instrument media is none of them; it is the CAPTURE_R2 trust-sibling (published content, not a D1 plane). The allowlist stays single-entry. C1 (honesty to ourselves): R2 bindings cannot be platform-scoped read-only, so the amended prohibition and this ADR say "read-path-only BY CODE DISCIPLINE — no put/delete call sites on this surface", never "read-only binding"; the guarantee is the discipline plus content-addressing, and G4 greps the surface source per gate to prove no write-verb call exists. 5. Digest scoping — D2 ruled token-plus-digest. A live token plus the sha256 digest is the fetch capability — the digest is capability-grade unguessable, the content is shared published question-media (no per-candidate or result leakage), and it reaches a candidate only through their own served projection (signed-URL semantics). A pack-membership check is rejected: the pack lives in LLND_DB (unbindable here), so it would force a per-request pack load across the seam. Designated day-two upgrade path (if a listening brief wants tighter scoping): stamp served digests onto the candidate's exposure/served row and check membership by a single indexed lookup — no pack load.

Consequences. - The candidate surface gains one read-path-only R2 binding (LLND_ASSETS_R2) and one GET route; no allowlist change, no LLND_DB/OPS_DB/send_email, no new outbound. The proxy-via-internal-api fallback is retired unexercised (specified in the G2 design record if ever needed). - The T2 rider retires the candidate surface's NRT_DB binding (both envs) in the same brief — config + Env type member, no-logic-change (no active reader existed). - EXT-API RULE: n/a this brief — fixture audio is locally generated (tones/silence, throwaway). The rule fires when a real TTS/voice pipeline engages; a reference doc lands in docs/ops/ before any such integration deploys. - Dev-first, prod inert: dev bucket + serving at G3; prod bucket created empty at G5 (bucket-before-binding), prod candidate-surface redeploy inert (0 prod packs; unknown digests 404). - MACHINERY-01 N2 (duplicate canon()): the tooling copies are now single-sourced via llnd-canon.mjs; the Workers/Next TS canonicalisers (llnd-conduct.ts, rem.ts) are a separate duplication in a different build root, not reachable from a Node helper — that consolidation remains day-two.

ADR-069 — Candidate LLN sitting client + conduct-session state + keyless routing round-trip (W1 closed)

Status: Accepted Date: 2026-07-12 (LLND-LLN-SITTING-UI-01) Relates to: conduct-design §6 (sequence + interaction, RC-L1–L11) · RC-L3/L3-A (routing + sealed manifest) · RC-L4 (asset substrate, ADR-068) · ADR-067 (routing machinery) · ADR-030 (internal-api isolation, INTAKE-CLASS prohibition) · foundation.md · llnd-02 §4.2/§6/§8.

Context. The candidate surface served only a minimal HTML shell; there was no real LLN sitting UI anywhere. This brief builds the first candidate-facing sitting client, projects the audio_ref/replay_limit the client needs, adds durable resumability, and closes the last open rung item — W1 (re-derive interleaving): form selection was re-derived at submit, so concurrent/resumed sittings could make the sealed manifest misdescribe what was rendered.

The decision. 1. Sitting client — worker-served, self-contained (ADOPT, G2). A dependency-free, mobile-first HTML/CSS/JS client in src/sitting-client.ts, served on the text/html path of GET /s/:token (pre-resolve; token liveness enforced on every JSON/conduct call). It drives the conduct sitting and falls back to the intake offer on 409 NO_CONDUCT. foundation.md tokens verbatim via var(). No framework, no assets binding (Static Assets can't token-gate), no allowlist change. 2. Projection (Op A). safe() gains audio_ref + replay_limit (both flat + routed). answer_key/competence_id/level_target stay server-side. P4: routing_rule is removed from the candidate projection — its only purpose there was local routing, which never worked without keys; it stays a sealed pack property, seam-side. 3. Conduct-session state (Op C) — transient, not custody. llnd_conduct_session (PK review_ref): pinned_forms (per skill × band, A1, with pinned exhaustion), routed_bands, saved_responses, sealed_blocks. Swept at 15 days with the interaction buffer; cleared at submit. A swept, never-submitted sitting leaves no sealed artefact (the MACHINERY-01 adjudication holds). Read-back is own-review, answer-key-free by construction. 4. W1 CLOSED by pin-at-open (Op D). Session-open is create-or-resume: form selection is pinned once at first open (per skill × band, with exhaustion). PK review_ref + INSERT OR IGNORE → concurrent opens converge on one pin. Submit scores the pinned forms (the re-derive path retires for routed packs); a pinned form that fails to resolve → 409 PIN_MISMATCH before any seal. The manifest records pin_provenance (the served forms) so replay-by-artefact describes exactly what the candidate saw. 5. Keyless routing round-trip (P1–P3, G3-STOP ruling). The client cannot route locally (no answer keys). POST /llnd/conduct/route (under the existing /llnd/conduct/* allowlist → no allowlist change; consent-gated relay) takes a skill's locator responses, computes the band with keys server-side, and returns the band label only — never the score, never keys. One-shot per skill (P1): the first route fixes the band in routed_bands; later calls return the stored band without recomputation (probe-resistant). Route is the locator's seal point (P2): locator answers are non-revisitable after routing (client frame rule + a locator_sealed interaction fact). Submit scores the stored band (P3), and the band recomputed from the submitted locator responses is a tripwire only → 409 ROUTE_MISMATCH, nothing sealed, if a (buggy/adversarial) client changed locator answers after routing — W1's misdescription cannot re-enter through the routing door.

Defect attribution (on the record). The "client routes locally" claim originated in a MACHINERY-01 code comment written when the seam was API-only (no client to falsify it) and travelled uncaught into this brief's §2, the G2 design, and the G2 verdict. A validate-load-bearing-assumptions instance — caught on paper at G3 build, before any code shipped against it.

Consequences. - The candidate surface gains the sitting client + one seam relay each for save and route (both consent-gated, both under the existing allowlist); no LLND_DB/OPS_DB/send_email binding, no allowlist change. C1 (read-path-only LLND_ASSETS_R2 by code discipline) holds — the client only GETs assets; grep-proven. - Digital (flat) packs ride the same client (§0.3): one block, no routing/audio, same item grammar; conduct-seam byte-identity is the G4 regression. - The interaction event vocabulary extends (block_start/block_complete/audio_play/locator_sealed/device-class) — disclosed-class facts on the existing buffer route, never scored (strata wall). - Block order = pack routing insertion order (already sealed bytes; no schema field). Dev-first, prod inert (migration + redeploys at G5; no LLN pack in prod). - The two scorer observations (per-item pilot not excluded; numeric tolerance absent) are filed forward to the pack-publish op register with N1 — not actioned here.


ADR-070 — Sanctioned dev-only bootstrap-seed exception to CANONICAL-IDENTITY-VIA-UI-ONLY for phantom demo tenants

Status: Accepted (2026-07-21 — DEMO-TENANT-ENV-01 Gate 2 proved it: demorto's three T4 bootstrap grants seeded into rto-identity-db-staging, and demorto switched between the demo tenants against them through the live /api/client path, re-scoping the session exactly as a UI-minted grant would. Proposed→Accepted flipped in this close commit, maintenance-on-write.) Date: 2026-07-21 (DEMO-TENANT-ENV-01) Relates to: the CANONICAL-IDENTITY-VIA-UI-ONLY standing rule (the rule this carves an exception in) · ADR-024 (T-tier canonicalisation + the zero-UUID admin architectural exception — the precedent for a single sanctioned seeded identity row) · ADR-025 (dedicated rto-identity-db) · ADR-026 (cross-DB FK-as-comment; client_id == identity-db == ops-db orgs.id == client_files.client_id) · the demo-tenant architecture decision record (2026-07-21) · standing-rules HARD SEPARATION ops-db invariant.

Context. CANONICAL-IDENTITY-VIA-UI-ONLY requires that identity rows — users, credentials, magic_link_allowlist, and tier_grants — enter the canonical model exclusively through user-interface paths ("one way in and one way out, through the UI"), so the operator can always see what is in the identity substrate and nothing drifts in by manual insert. The rule was minted precisely against forgotten manual inserts.

The demo-tenant environment needs demorto@rtopacks.dev to hold a membership grant in each phantom demo tenant so the tenant switcher has something to switch between. This runs into a structural fact: a phantom tenant has no registration or approval event. Real tenants bootstrap their first administrator's grant at RTO registration / onboarding-approval (the UI path that mints via mintGrantsForInvite); a fresh, empty demo tenant has no existing member to send the first invite and no registration to approve. The first grant into an empty tenant is therefore inherently a seed, whatever the policy — there is no UI actor able to mint it. This is the same shape as the ADR-024 zero-UUID admin: a single, architecturally-necessary seeded identity row that the UI-only rule already tolerates because a UI path cannot, by construction, produce it.

The decision. Seed exactly one T4 tier_grant per demo tenant for demorto@rtopacks.dev as a sanctioned, dev-only exception to CANONICAL-IDENTITY-VIA-UI-ONLY, contained as follows:

  1. Dev/staging only. The grants are written to rto-identity-db-staging only. No phantom grant is ever written to a prod database. Prod never contains a demo tenant or a demo grant.
  2. Self-documenting row. Each seeded grant carries notes = 'demo-synthetic bootstrap grant — DEMO-TENANT-ENV-01; dev-only; CANONICAL-IDENTITY-VIA-UI-ONLY sanctioned exception (Tim-ratified 2026-07-21)'. The exception travels on the row itself — it cannot be "forgotten in a database," which is the exact failure the rule guards.
  3. Not a divergent path. The seed reuses mintGrantsForInvite's exact INSERT + ON CONFLICT(user_id, tier, client_id) DO NOTHING shape (is_primary=1, the bootstrap admin). Idempotent (re-runnable) and reversible (torn down with the demo tenants by provenance/client_id).
  4. Bootstrap only. This exception covers the first grant into each empty demo tenant and nothing else. Every subsequent demo membership is added through the real add-team UI — real code, real gating. The exception is a cold-start carve-out, not a standing licence to seed identity. (Note: demorto's users row is NOT seeded — it is created UI-canonically by one magic-link login; the allowlist row that permits that login is normal operator seeding of the operator/seed allowlist, not part of this exception.)

Consequences. - The rule stands unamended. This is a named, bounded carve-out — the same category as the zero-UUID admin — not a weakening of CANONICAL-IDENTITY-VIA-UI-ONLY. - Prod identity is untouched; the operator's prod UI-visibility guarantee is fully preserved. - The demo environment is reversible and provenance-firewalled end to end (provenance='demo-synthetic' on the client rows, the notes tag on the grants). - No ops-db is touched: the grants are identity-db, the tenants are client-db (HARD SEPARATION / ops-db invariant intact). - If a future need arises to bootstrap tenants through a real provisioning UI (so even the first grant is UI-minted), that UI supersedes this exception for tenants it creates; this ADR is then narrowed to the phantom-demo case or retired.

Flips to Accepted when DEMO-TENANT-ENV-01 seeds the bootstrap grants and demorto switches between the demo tenants against them (Gate 2) — proving the seeded grants function as real membership through the live switcher path, exactly as a UI-minted grant would. The proving brick flips this status in its own close commit (maintenance-on-write).


ADR-071 — Owned RTO content is 100% private per-RTO, enforced at the data layer via the owner stamp

Status: Accepted (2026-07-22 — DEMO-TENANT-CONTENT-02 Gate 3 proved it on independent bytes: the owner-scope reader is the only owned-content read path (grep-proven repo-wide), single session-bound caller, fail-closed; and the live cross-tenant negative test returned zero on every axis for a second real tenant — cli_demo_agedcare reads cli_demo_electro's loaded UEEEL0039 content → nothing, no existence signal.) Date: 2026-07-21 (Tim's content-ownership ruling) → codified 2026-07-22 (DEMO-TENANT-CONTENT-02 close). Relates to: the content-ownership ruling (Tim, private-per-RTO + version-anchoring addendum) · DEMO-TENANT-CONTENT-01 (the owner stamp + version anchor + fable-reference load — the stamp) · DEMO-TENANT-CONTENT-02 (the owner-scope reader — the enforcement) · ADR-024/026 (client_id is the canonical tenant key; client_id == identity-db == ops-db orgs.id == client_files.client_id) · standing-rules HARD SEPARATION ops-db invariant.

Context. RTOpacks holds two distinct classes of content. The public standard — the TGA/Pith qualification and unit tree (elements, performance criteria, evidence as published by training.gov.au) — is shared: every RTO teaching a unit sees the same national requirement, and the Studio canvas reads it live from the public proxy (/api/studio/units/{code} → internal-api). The owned build — everything an RTO produces on top of that standard: the structured requirement spine it grounds against, its teaching content, its assessment instruments, its RPL tooling — is the RTO's own intellectual property and must never be visible to another RTO. Tim's ruling: owned content is 100% private per-RTO, and the guarantee is enforced at the data layer via the owner stamp — not UI filtering (UI filtering is a display convenience that a second surface, a bug, or a direct query trivially bypasses; the guarantee must live where the bytes are read).

The decision.

  1. Ownership is born with the data (the stamp — CONTENT-01). Every owned row carries its owner. The course family stamps owner_client_id on the course apex (and source_unit); children (course_module, course_artifacts, course_source_map) inherit ownership through their course_id FK. The triad family scopes by the existing triad_envelope.client_id; outcomes/criteria/evidence inherit through the envelope FK. This follows the schema's own grain — no per-row owner denormalisation onto leaves.

  2. Enforcement is a single owner-scope read path (CONTENT-02). All owned content is read through one module (apps/workspace/lib/owned-content.ts) whose every SELECT carries the owner predicate literallyowner_client_id = ? (course side) or triad_envelope.client_id = ? (triad side, children joined up to the owned envelope). The tenant key is the session-resolved client_id (resolveStudioScope → scope.orgId, ADR-024), never a request parameter — so the predicate cannot be forgotten or spoofed by a caller. Fail-closed: an unmatched/absent owner yields empty, never unscoped rows, never an existence signal. Every future content reader (assessment, RPL, render) MUST carry the owner-scope predicate — that is the enforcement point, and Gate 3's live cross-tenant negative test is its proof.

  3. Public requirements stay shared. Where an RTO has owned content it is served from the owner-scope reader; where it does not, the public standard proxy remains the fallback. Public shared, owned private.

  4. Version-anchoring addendum. Owned content is anchored to the authoritative training-package release (source_unit.version/release_date; triad_envelope.corpus_version/corpus_hash). Currency is read off the authoritative status, never superseded_by/superseded_by_code (the repeatedly-confirmed inverted-column landmine — a surface that trusts it shows supersession backwards).

Consequences. - The ops-db invariant is strengthened, not merely met. Resolving this made the Studio scope route drop its last ops-db read entirely (CONTENT-02 Option A) — the customer surface comes fully off ops-db, below the invariant's floor. New owned-content readers bind WORKSPACE_DB only. - The guarantee is executable and tested, not aspirational. The private-per-RTO promise is a live data-layer test (Gate 3), reproducible by re-running the reader as any second tenant. - Owed shape fix before multi-unit tenants (F-multi-unit). The CONTENT-02 reader uses .first() for the single envelope/source_unit — correct for a one-unit tenant, but it would merge leaves across units (and pick an arbitrary anchor) for a tenant owning >1 unit. This is a correctness/shape bug, not an isolation breach (every query stays tenant-scoped). It is filed forward as an acceptance criterion of the render follow-on and must be fixed before any tenant owns more than one unit. - Render of owned content is deferred to a dedicated follow-on (the reader ships the data; the Studio surfacing of it — per-leaf spine, teaching modules, visible anchor + provenance badge — is net-new UI against the design system).


ADR-072 — Synthetic demo-RTO scope surfaced via a client-db demo-synthetic overlay; the Pith stays read-only

Status: Accepted (2026-07-22, DEMO-TENANT-SCOPE-01).

Context. The Studio "On Scope" panel reads on-scope qualifications from the national register (the Pith, rto-nrt-db) via internal-api GET /rto/{rto_code}/scope/studio. Synthetic demo RTOs (status='demo', e.g. DEMO-ELECTRO) are not ASQA-registered, so they have no rows in the Pith and correctly rendered "scope is clear." Their authored demo scope had been seeded into rto-client-db rto_scope_v2 with source='demo-synthetic' — a table the surface never read.

Decision. The scope/studio handler runs an additional overlay query against CLIENT_DB rto_scope_v2 filtered WHERE rto_code = ? AND source = 'demo-synthetic' AND nrt_type = 'Qualification', concatenated Pith-first through the unchanged ok() response contract. The seeded demo rows are stamped nrt_type='Qualification', usage_recommendation='Current' to match canonical scope shape. source='demo-synthetic' is the load-bearing guard: real tenants carry provenance='regulator-verified' / source='rto-nrt-db:rto_scope_v2' and can never match it. The overlay runs unconditionally and is safe because the two result sets are mutually exclusive — a real RTO yields 0 overlay rows; a demo RTO yields 0 Pith rows — so there is no double-count and no real-tenant exposure.

Consequences. - Synthetic demo RTOs surface their authored scope in Studio without any synthetic rows being written to the national register — the Pith stays read-only (hard rule preserved). - Demo scope is dev/staging-only; prod rto-client-db has zero demo-synthetic rows, so the prod overlay is empty (verified) — the deployed code is pure equalisation in prod. - The guard depends on marker discipline: any future authored-but-unregistered scope intended to surface must carry source='demo-synthetic' (or the filter must be widened by a deliberate decision), and a real tenant must never be stamped demo-synthetic. - Separation: internal-api reads CLIENT_DB + RTOPACKS_DB only for this path; no ops-db. - Related: builds on ADR-071 (owned content private per-RTO). The qual→units drill render is a separate pre-existing gap — the canvas renders from qualification_packaging_rules.clusters (null for quals with no capture, e.g. UEE30820), not from the units array handleQualDetail returns. Its fix is a code fallback to qualification_units when packaging_clusters is null — never by writing clusters into the Pith.


ADR-073 — Points-based (weighting-point) packaging is detected live from the TGA source prose at read-time; the Pith is never written

Status: Accepted — 2026-07-22. Supersedes an earlier same-day draft that proposed a stored packaging_basis column populated by migration + bulk UPDATE; that draft was withdrawn before any write (see History).

Context

  • SCOPE-01 (ADR-072) surfaced UEE30820; its Studio canvas drew 120 empty elective slots. Root cause: the electrotechnology/energy training packages package qualifications in weighting points, not unit counts, and the parsed qualification_packaging_rules.rules[].count (990 core, 120 elective) — which are points — were rendered as literal unit slots.
  • Detection finding (read-only substrate, prod Pith rto-nrt-db 1249760d). The model is stated in plain text in each qualification's TGA packaging prose (qualifications.packaging_rules). UEE30820 reads, verbatim: "A total of 1,110 weighting points comprising: 990 core weighting points… 120 general elective weighting points… up to 40 general elective weighting points may be selected… from any relevant nationally recognised unit." The signature "weighting point" appears in 316 qualifications across exactly four packages (UEE 214, UEP 42, UET 31, UEG 29) and nowhere else in ~8,000 quals — zero false positives — and discriminates per qualification within each package (UEE 214 of 424; UEP 42/52; UET 31/73; UEG 29/39).
  • The earlier count-based heuristic (total_required ≥ 100 → 149 quals) was both incomplete and wrong: it missed UET entirely (4 vs 31), missed ~92 UEE, and risked out-of-family false positives. Counts are the mis-typed proxy; the prose is ground truth.

Decision

  1. Points basis is derived at read-time from the authoritative TGA packaging prose (qualifications.packaging_rules), via the "weighting point" signature, in the internal-api packaging path — the same layer that already extracts packaging sentences from that prose. Nothing is stored; nothing is written.
  2. The Pith (rto-nrt-db) read-only invariant holds without exception. This feature performs no schema change, no data write, no write of any kind to the register. The register's only sanctioned writer remains the packaging pipeline; this feature adds nothing to it and asks for no execution nod.
  3. Studio renders points quals as an open point-target elective group; the basis and the point figures (core / elective / "up to N elsewhere") are read from the prose (truth), not from the mis-typed parsed counts. Core renders unchanged.
  4. Detection is deterministic (string match), per-qual (not per-package), verified zero-false-positive corpus-wide. Fail-safe: absence of the signature ⇒ basis 'units' (today's behaviour) — units quals are provably untouched.

Rule note — why no exception was made

The register-write prohibition is a bright line. Its value is that it has exactly one sanctioned door — the governed packaging pipeline — and no side doors, regardless of how small the write. A migration + bulk UPDATE bolted onto a feature brief was a side door; it was withdrawn before execution. "It's trivial" is precisely the argument that erodes bright lines, so triviality is a reason to hold the line, not to cross it. Holding it here cost nothing: the read-time design is also the more elegant one. Where register data genuinely belongs one day (e.g. per-unit weighting extraction), it enters through the pipeline door — that is the rule operating, not bending.

Consequences

  • No execution nod for a register write, no migration, no rollback surface — the riskiest part of the original plan is gone.
  • The count-heuristic is retired; UET is reclassified points-based; the working cohort is ~316 (prose-defined), not 149.
  • Per-unit weighting-point extraction (the 10/20/40/60/80 values — a heavier parse of the prose table) is Phase 2 (POINTS-MODEL-02). If caching those ever proves warranted, it is decided then, on evidence, and lands through the pipeline door into a governed/derived location — never a hand-write to the register.
  • An On-Scope "licensed qualifications" marker, if wanted, rides the same read-time detector.

History

An earlier 2026-07-22 draft proposed a stored packaging_basis column populated by migration + bulk UPDATE over a count-based cohort. Withdrawn before any write, on two grounds raised in review: (a) counts mis-detect the cohort — the prose is ground truth; (b) writing the register for a trivial convenience erodes a bright-line invariant. Replaced by read-time detection.

Relates to

Extends the existing STUDIO-PACKAGING extract-from-prose pattern (the verbatim sentence extractors in the qual-detail proxy). Subordinate to the client spine and WS-PRODUCT-01. Detection evidence: claude/points-detection-prose-2026-07-22.md; discovery recon: claude/points-packaging-recon-2026-07-22.md. Reinforced by PITH-SEAL-01 (standing-rules.md).


ADR-074 — Points electives ride the declare-pattern append rail, tally to the elective total, and hold concurrency by salience not ownership

Status: Accepted — 2026-07-23 (Tim ratified this session). Decisions 1–3 govern POINTS-MODEL-02's build shape. Decision 1's code proof lands at POINTS-MODEL-02 Gate-C (the isolated DO gate with the slot_*/cluster_* regression walk); this ADR records the decision, not the proof.

Date: 2026-07-23.

Relates to: ADR-073 (points-based packaging detected read-time; the Pith is never written) — this ADR is the interactive successor's mechanism layer · PITH-SEAL-01 · the v2.8 Time Machine (whose stated slot_add mechanic Decision 1 corrects) · POINTS-MODEL-01 (read-only render, closed b4e2cf07) · POINTS-MODEL-02 brief + G1 verdict (claude/POINTS-MODEL-02-brief-2026-07-22.md, claude/POINTS-MODEL-02-gate1-verdict-2026-07-22.md) · the sizing pass (claude/POINTS-MODEL-02-sizing-pass-2026-07-22.md).

Context

POINTS-MODEL-01 shipped the read-only render of a points-based elective group (ADR-073's read-time detector feeds it): an open point-target group with a pool of eligible units and no fixed slots — because a points qualification has no slot bank, you assemble electives freely until their weighting points reach a target (e.g. "120 points"). POINTS-MODEL-02 makes that pool interactive: add/remove electives, a running points tally, real readiness. Three mechanism questions had to resolve before the build could proceed past its decision-independent Part A. All three were byte-verified against the live collab DO (workers/studio-collab-do/src/index.ts) this session, staged from the bridged working tree.

Decision

1. The add/remove op is elective_add / elective_remove, modelled on the cluster_declare append pattern — NOT slot_add mirroring slot_fill. This corrects the v2.8 Time Machine's stated mechanic.

Byte evidence. handleSlotFill (DO :300) looks up a pre-existing slot by id and sets its filled_unit; if no slot with that id exists the mutator returns false and the op fails not_found (:317–333). It structurally cannot append, and points groups ship with no slot bank (slots: [] — the 01 finding), so slot_fill can never serve them. The correct primitive already exists: handleClusterDeclare (:410) mints a server id (crypto.randomUUID(), :421) and pushes a new entity into a payload array (payload.clusters.push, :433) through the shared optimistic-concurrency rail mutatePayload (:664), then broadcasts write_ok with the updated payload (:457) and logs an event (:450). handleClusterDelete (:544) is the removal analogue (payload.clusters.filter, :552). elective_add/elective_remove copy that pattern onto the same rail.

The lift is deliberate about what it does NOT inherit. cluster_declare carries two cluster-specific guards — a hard ≥2-units rejection (:417) and one-cluster-per-unit enforcement (:428–431) — that electives must not adopt (an elective is a single unit, freely added). elective_add drops both and substitutes its own invariants inside the mutator: the unit must be in the group's eligible pool, and no duplicate unit in the group. Copying the mechanism on an existing pattern is sanctioned; inventing a new interaction paradigm is not — the add/remove op passes that test.

This revises the v2.8 Time Machine ("slot_add — new message type mirroring slot_fill"). The canonical record is corrected here: the mechanic mirrors cluster_declare, not slot_fill.

2. POINTS-MODEL-02 tallies to the elective TOTAL; per-group range enforcement is POINTS-MODEL-03.

A points qual states a headline elective total (e.g. 120) and, in the majority of cases (sizing pass: ≥174/316), also states per-sub-group min/max constraints (Group A ≥20, Group B ≤40, …) that live only as written prose. Parsing and enforcing those sub-group ranges is a dedicated structured parse — POINTS-MODEL-03. 02 tallies toward the headline total for all 316 quals, and stays honest by rendering the verbatim packaging-rule prose on screen as guidance (existing GUIDANCE_RE/BREADTH_RE), making no per-group compliance claim. For multi-group quals the node shows "point total reached · group rules in guidance" so "met" never overclaims. A group_constraints field is reserved (null in 02) so 03 extends rather than retrofits.

The accepted gap: a user may reach "120/120 — met" while breaching a Group A minimum, and 02 will not stop them. 02 manages the gap by keeping the authoritative rule text visible and never letting the tally claim full compliance; closing it is 03's charter.

3. Concurrency on a points group is held by salience-on-change over the existing broadcast rail — NOT by an ownership/approval workflow.

Points readiness is a sum invariant, not a set of filled boxes. In the slots model a cleared slot leaves a visible empty box; in points, a concurrent removal of a 20-point unit silently turns "120/120 met" into "100/120 under" — the hole is a number that moved, easy to miss. The identified risk is real. The resolution is the light one that rides rails already in place: write_ok already broadcasts the updated payload plus a lastEditor (:351–360) and the client already renders an amber last-editor pill, so a removal is neither silent nor unattributed today — what is missing is salience. 02 adds a prominent change-notification when a write moves the local group's total, especially downward across the met→under line ("Alex removed UEECD0050 (−20) — now 100/120, under target").

The ownership/approval model (a proposer's change requires an owner's approval) is explicitly rejected for now on two grounds: it cuts against the DO's optimistic-concurrency design grain (last-write-wins with a version check), and — load-bearing — it requires real per-user identity and roles that the collab DO does not yet have (identity is a hardcoded placeholder, org_rtopacks_ops, with the literal comment "real auth wiring comes later", :701–703). Building a per-user approval queue on placeholder identity would be building on absent substrate — the exact failure the validate-load-bearing-assumptions rule exists to prevent. Ownership/approval is reserved as a filed candidate (POINTS-CONCURRENCY, pointer only) gated behind the auth-wiring milestone; when real identity lands we revisit whether approval is still wanted, or whether salience — plus, if concurrent same-group editing proves common, a group-level soft-lock extending the existing activeSlotId precedent (:306–314) — already suffices.

Consequences

  • The shared collab DO gains exactly one new op-pair (elective_add/elective_remove) on an existing pattern — no new interaction paradigm, so the Part-C regression walk (slot_fill/slot_clear/cluster_declare/update/delete byte-unaffected) grows by a bounded, known amount and not by a new class of behaviour.
  • The v2.8 Time Machine's slot_add mechanic is retired; anyone reading it should treat this ADR as the correction.
  • Salience adds zero new DO regression surface — it consumes write_ok's existing payload and lastEditor; the change is client/node only.
  • 02 ships an honest interactive tally for all 316 points quals without a per-group compliance claim; 03 is pre-shaped by the reserved group_constraints.
  • Ownership/approval is captured, not lost, and explicitly blocked on auth so it cannot be quietly built on placeholder identity.

Relates to (evidence)

Byte verification of Decision 1 performed this session against the live repo file workers/studio-collab-do/src/index.ts (staged from the bridged working tree, 30,363 bytes): handleSlotFill :300, handleClusterDeclare :410, handleClusterDelete :544, mutatePayload :664, write_ok/lastEditor :351, soft-lock precedent :306, placeholder identity :701. Decisions 2 and 3's cohort/shape facts rest on the already-gated sizing pass (claude/POINTS-MODEL-02-sizing-pass-2026-07-22.md) and G1 verdict.

ADR-075 — A change to a shared prod-only Durable Object gates on a dev DO twin (dev-parity) as standard; the local-isolated → rollback-armed-prod walk is the documented fallback

Status: Accepted — 2026-07-23, proven by the DEV-DO-PARITY-01 close (dev twin built + Gate-4-verified this session). Records the decision; the proof is DEV-DO-PARITY-01's gates. Ratified by Tim at the DEV-DO-PARITY-01 Gate 5 sign-off, 2026-07-23 (G5 closed on two commits — config 52fa5767 → governance c4c10f3a — both pushed; pending-ratification note cleared 2026-07-26).

Date: 2026-07-23.

Relates to: POINTS-MODEL-02 Part C — the change whose gating exposed the gap (claude/POINTS-MODEL-02-partC-brief-2026-07-23.md, claude/POINTS-MODEL-02-partC-gateC-verdict-2026-07-23.md, claude/POINTS-MODEL-02-partC-GateC-runbook-2026-07-23.md) · ADR-074 (the Part C build this gating served) · DEV-DO-PARITY-01 brief + G1–G4 verdicts (outputs/DEV-DO-PARITY-01-*) — the brick that proves this ADR · the standing-rules validate-load-bearing-assumptions rule and the deploy-verification waiver ladder (workers.dev fallback / D1-direct / same-commit-as-dev waiver), beside which this ADR's fallback clause sits · the ops-db customer-surface invariant (2026-07-04) + PITH-SEAL-01 (the dev twin holds both).

Context

POINTS-MODEL-02 Part C changed the shared collab DO rtopacks-studio-collab — a single-env, prod-only worker (no env block; the workspace dev env binds no DO). With no dev DO surface, Part C could not gate on dev: it used a local-isolated wrangler dev gate → a rollback-armed prod deploy → a throwaway-session regression walk against prod. It worked and closed clean under discipline, but it gates a customer-facing shared DO's change by putting the change on prod first, behind a rollback — tolerable once, structurally wrong as a standing pattern. The missing thing was a dev twin, and every future collab/DO change (RADAR-CF, further Studio) hits the same wall.

DEV-DO-PARITY-01 stood the twin up: rtopacks-studio-collab-dev on rtopacks.dev, bound to rto-workspace-db-staging (studio schema byte-identical to prod engine-db-oc, including the event_type CHECK) and dev SESSION_KV, with its own DO namespace + migration ledger — config-only, mirroring the apps/workspace env.dev precedent.

Decision

1. Dev-parity is the standard gate path for a change to a shared prod DO. A shared / customer-facing Durable Object change gates on a dev DO twin — bound to the staging copy of its store, exercised on the dev zone — not on prod-behind-rollback. The prod deploy becomes an equalise step after the dev gate passes, not the gate itself.

2. The local-isolated → rollback-armed-prod walk is the documented fallback, used only when the DO has no dev twin yet. It is not retired (a newly-created DO may have no twin on day one), but it is second choice; the preferred response to hitting it a second time is to stand up the twin.

3. The shape of a dev DO twin (the reusable pattern): an env.dev block on the DO worker's wrangler.jsonc that re-declares the full binding set — a dev route more specific than the workspace-dev catch-all (so CF routes the WS upgrade to the twin), the staging D1 (schema-parity verified by query, not assumed), dev KV, the DO binding, and — per wrangler v4 — the migrations block inside env.dev (each named env is a distinct script → its own DO namespace → its own migration ledger; validated by dry-run at DEV-DO-PARITY-01 Gate 1/B3). Bind no ops-db and no Pith on the twin (the customer-surface invariants hold in dev too). The twin is a standing surface — kept deployed, not torn down after the walk.

Consequences

  • Future shared-DO changes gate on dev; prod stops being the first gate surface for its own change.
  • rtopacks-studio-collab now has a standing dev twin; the next Studio/collab DO change (and RADAR-CF) uses it.
  • Staging-schema parity is a precondition, verified by query at brief-draft time — a twin on a drifted staging DB is a false gate, not a gate.
  • The fallback clause keeps the ADR honest for a brand-new DO with no twin; the bias is to build the twin on the second hit.
  • Sits beside the deploy-verification waiver ladder: where those waivers concede a verification shortfall on a prod-CF-Access-gated worker, this ADR removes the need by giving the change a real dev surface to gate on first.

Relates to (evidence)

DEV-DO-PARITY-01 gates 1–4, all PASS on bytes this session: G1/B3 dry-run (migrations-in-env.dev accepted, no warning); G3 real-file config verify (wrangler.jsonc 2339 B, prod keys byte-identical, dev block binds no ops-db/NRT); G4 live walk (WS lands on rtopacks-studio-collab-dev by worker-tail; server-authoritative points; zero elective_* event rows on rto-workspace-db-staging with slot/cluster logging intact; prod DO modified_on unchanged). Substrate: workers/studio-collab-do/wrangler.jsonc, apps/workspace/wrangler.jsonc env.dev, and rto-workspace-db-staging (25995e53…) studio-table DDL parity with engine-db-oc (81e2919a…).


ADR-076 — Imported-unit points authority is server-side and set by the receiving qualification; the collab DO takes its first read-only Pith binding; prerequisites come from the structured table

Status: Accepted — 2026-07-24 (ratified at IMPORTED-UNIT-SELECT G1). Proven across Parts 0/A/B/C/D — all PASS on bytes; Part D closed on a live re-walk 2026-07-26. Filed to canon 2026-07-26; amended twice the same day — see Amendment 2026-07-26 below. Decision 1's assigned VALUE (the constant 10) is FALSIFIED and owed a fix; Decisions 1-authority, 2, 3 and 4 stand. Supersedes: nothing. Extends: ADR-073 (points detected read-time from TGA prose), ADR-074 (declare-rail append pattern), ADR-075 (dev-parity is the standard gate for a shared prod DO). Preserves: PITH-SEAL-01 (Pith SELECT-only).

Date: 2026-07-24.

Relates to: ADR-073 · ADR-074 · ADR-075 (this ADR's DO source + binding change is the first real exercise of the dev-parity gate that ADR-075 made standard) · PITH-SEAL-01 · the RADAR-REGISTER-01 ops-db-invariant amendment (2026-07-04), which is what sanctions a customer surface reading the Pith · POINTS-MODEL-03 (which shipped the elsewhere-cap dormant, waiting on this) · IMPORTED-UNIT-SELECT brief + Part 0/A/B/C/D verdicts (outputs/IMPORTED-UNIT-SELECT-*).

Context

The TGA open-corpus allowance — "up to N weighting points … from any relevant nationally endorsed Training Package or accredited course" — could not be exercised. The Studio picker offered only the qualification's listed pool, and the collab DO's add-path rejected any out-of-pool unit (studio-collab-do/src/index.ts:610-611). POINTS-MODEL-03 shipped the elsewhere-cap enforcement dormant, waiting for imported selection to make imported-points reachable at all.

Substrate facts, verified on bytes and against the prod Pith (SELECT-only):

  • Imported-unit weighting points feed the elsewhere-cap, a readiness gate — a wrong value mis-fires a compliance judgement.
  • A weighting point is not a property of a unit. The units table has no points/weighting column, and no unit of competency carries a weighting anywhere in TGA. Points are assigned by the receiving qualification's training package — the same unit imported into two different points quals may be weighted differently by each. This is what makes the open-corpus allowance coherent: the importing qual sets the exchange rate for outside units.
  • The weighting for an imported unit is resolvable, and the source is data we already hold. The packaging prose points at the package's Companion Volume Implementation Guide (CVIG): "Previously assigned weighting points are listed in the … CVIG, if not listed weighting points will be 10 points." That pointer is misleading. The UEE CVIG carries no weighting table at all (verified by full-text extraction of Release 10.0, pdftotext -layout, 77 pp, sha256 5e924b6f…, 1,520,134 B — three occurrences of "weight" in the document, one of them a unit title). What the CVIG actually states (printed footer Pg 31; PDF page 32), verbatim: "If weighting points are not listed for an imported unit in any other UEE qualification, default weighting will be 10 points." The authority is other qualifications of the same package — and their weightings sit in qualifications.packaging_rules as a three-column HTML table (code · title · points), already in the Pith. Confirmed in UEE42925: BSBESB402 = 60, BSBOPS203 = 20. The parsed qualification_units table has no points column — the weighting column was dropped at parse time and has never been extracted. Page-count correction 2026-07-28 (authorised by Tim): 78 → 77 — split-on-form-feed counted the trailing empty chunk; pdfinfo page tree and non-empty-chunk count both read 77 (close-amendment-1 §g).
  • A class of imported units is exempt from the cap entirely. The UEE CVIG (printed footer Pg 31; PDF page 32): "Where any of the Units of Competency listed below are used to form a qualification or Skill Set, they do not count as Units of Competency imported under the qualification packaging rule." The list runs to 92 units across twelve packages (16 BSB · 2 CPC · 4 CPP · 2 HLT · 28 ICT · 8 MEM · 6 MSS · 1 PMA · 5 RII · 4 TLI · 3 UEP · 13 UET), identical code-for-code in R10.0 (sha256 5e924b6f…, 77 pp) and R11.0 (sha256 5f70d7df…). Correction 2026-07-27 (authorised by Tim): this entry previously stated 43 (16 BSB, 13 UET, 4 CPP, 3 UEP, 3 ICT, 2 HLT, 2 CPC) — a truncated read of the same document: ICT cut 28 → 3, five packages (MEM, MSS, PMA, RII, TLI) dropped whole. Measurement record and full enumeration: outputs/WEIGHTING-RESOLVE-01-partB-item4-HALT-exemption-count-2026-07-27.md (sha256 7bcd7275…). A naive set-difference charges every one of the 92 against the elsewhere-cap.
  • Most points quals state no default at all. Only 119 of 316 carry the "if not listed … 10 points" clause (UEE 85, UEP 17, UEG 11, UET 6). For the other 197 there is no documented fallback — what governs an imported unit's weighting there is an open question, not a settled 10.
  • The collab DO bound only WORKSPACE_DB — no Pith binding — while structured prerequisites already existed in the Pith: tga_training_prerequisites, 11,349 real edges across 3,042 units bearing prerequisites, carrying codes and titles. The obligation these serve is stated in the prose itself (UEP20218, verbatim): "Where imported units are selected, care must be taken to ensure that all prerequisite units specified are complied with."

Decision

1. Points authority is server-side, never the client (β-clean); the value assigned is the documented CVIG-absent default of 10. The import wire message carries unit_code only — no points, no title (index.ts:73). The DO resolves the title from the Pith (existence-validate, :701-704) and assigns 10 (:727), then pushes the unit directly to selected_unitsnot eligible_units. Because eligible_units stays the listed pool, importedPoints()'s set-difference (qual-tree-types.ts:250) identifies the imported unit unchanged → zero validator edits.

The load-bearing half of this decision is where authority sits, and that is settled: the client cannot set the weight (no weight field on the wire), so a compliance-gate input can never be supplied by the browser. The value is a different question and is wrong as shipped. The constant 10 is falsified on bytes (BSBESB402 is weighted 60, BSBOPS203 is weighted 20). The correct value is derivable from data already in the Pith. Replacing it is owed — see the amendment note in Consequences. Until then, every imported unit is assigned a weighting that may be off by up to 6×, into a compliance gate.

2. The collab DO takes its first read-only Pith binding (NRT_DB) — prod rto-nrt-db, dev twin rto-nrt-db-dev. Sanctioned by the ops-db-invariant amendment (RADAR-REGISTER-01): customer surfaces MAY read the Pith read-only. Cost is one primary-key title lookup per import. The dev twin binds the mirror, never prod Pith — a twin that omitted the binding would have been a false gate, and a twin that bound prod would have broken ADR-075's invariant; binding the mirror satisfies both.

3. Prerequisite surfacing is sourced from the structured tga_training_prerequisites table (index.ts:712-720) — exact named prerequisite units and titles, indexed by component_code, with honest-absence fallback. Not prose extraction; not a generic advisory note. An empty array means "no prerequisites recorded", which is explicitly not a claim that the unit has none.

4. The DO source + binding change invokes the ADR-075 dev-parity gate. Its precondition — a dev-mirror units + prereq slice — was cleared by Part 0 (close verdict 2026-07-24); dev rto-nrt-db-dev carries a 125-unit slice, units_fts (125 rows), and a scoped tga_training_prerequisites (693 edges across 121 distinct units). Count correction 2026-07-31 (authorised by Tim): 123 → 125. The 123 was the Part 0 close figure (2026-07-24); 125/125/693/121 was re-derived on the substrate at Part B Gate 5, independently by Alex and by Claude. Whether the slice grew after Part 0 or the 123 was wrong when written is not established — the delta is recorded, not explained.

Alternatives rejected

  • β-inject — inject the imported unit into eligible_units behind an imported flag and switch importedPoints() to read the flag. Edits POINTS-MODEL-03's shipped validator for no gain over β-clean.
  • α — the DO trusts client-supplied points. Lets the wire set a compliance-gate input. Rejected on integrity, not on effort. This rejection is unaffected by the weighting-source finding: that the correct value is uncertain is an argument for keeping authority server-side, not for handing it to the client.
  • Prereq via prose extraction, or a generic advisory note. A structured source exists, so prose is unnecessary; and a generic note under-delivers the compliance obligation it purports to meet.

Consequences

  • Zero validator edits — qual-tree-types.ts's computation logic is untouched; the file gains type declarations only (elsewhere_cap:187, imported_meta:192).
  • selected_units stays 3-field. Audit evidence (rationale + prerequisite snapshot + imported_at) lands on a parallel per-group map, imported_meta, keyed by unit_code (index.ts:729-734), which importedPoints() never reads. Rationale is server-clamped at 500 chars (:988), parity with the cluster rationale.
  • Removal cleans up its own evidence: elective_remove deletes the unit's imported_meta entry (:662-663), so a removed unit leaves no stale audit record. Proven by positive trace at the Part D live re-walk, not by inference.
  • No new event-log row. studio_session_events.event_type's CHECK permits only slot_*/cluster_*; elective_import logs nothing, matching elective_add. Salience rides write_ok, not the log.
  • Pith remains SELECT-only for all resolution (PITH-SEAL-01 held). The only Pith-family write in the entire lane was Part 0's slice into the dev mirror. HARD SEPARATION held — ops-db never touched.

Amendment 2026-07-26 — the constant-10 assignment is FALSIFIED, and the fix needs no external ingestion

Two successive corrections landed the same day; this section is the settled position and supersedes both the G1-ratified text and the first amendment.

Withdrawn (G1 text). "Corpus-uniformly 10; the caveat is intra-qual, so for a cross-package import the 10 is unconditional." Wrong — the 119 sentences are the fallback clause of a conditional rule.

Withdrawn (first amendment, same day). "10 is the CVIG-absent documented default … a known, bounded, deliberately-accepted approximation." Also wrong, and too generous. It framed the value as unverifiable pending an external ingestion. It is neither unverifiable nor external.

Settled position. The UEE CVIG (Release 10.0, verified by full-text extraction) publishes no weighting table. It redirects: "If weighting points are not listed for an imported unit in any other UEE qualification, default weighting will be 10 points." Those per-unit weightings are in qualifications.packaging_rules — a three-column HTML table already held in the Pith for all 316 points quals. The constant 10 is therefore not an approximation but an error, demonstrably wrong for common units: BSBESB402 = 60 (6× under), BSBOPS203 = 20 (2× under), both in UEE42925. A 6× understatement of imported points means the elsewhere-cap will fail to fire when it should.

Consequences for the build. The fix is a read-time parse of a column we already receive — the ADR-073 pattern exactly: derive from the authoritative prose at read-time, write nothing to the register. No CVIG ingestion, no new external API surface, no companion-volume storage, and therefore no EXT-API RULE trigger. Mitigation rung 3 of the first amendment (ingest CVIG weighting tables) is struck — the table does not exist.

Three things the fix must handle, all newly on the record:

  1. Cross-qual resolution. For an imported unit, resolve its weighting from any other qualification of the same training package; fall back to 10 only on genuine absence. Conflicting weightings across quals of the same package are possible and the resolution rule for that case is undecided.
  2. The exemption class. 43 named units do not count as imported under the packaging rule at all. importedPoints()'s set-difference must exclude them or the cap fires on exempt units. This is a defect in the detector, not only in the value.
  3. The silent 197. Only 119 of 316 points quals state a default. For the remaining 197 there is no documented fallback, and assigning 10 there is an assumption with no textual basis. This must be resolved or explicitly declared, not defaulted through.

Until the fix ships, imported-unit weightings must not be presented to a customer as authoritative. Labelling them stated-default, unverified is the minimum; suppressing the number is defensible.

Scope caveat. The above is verified for UEE (214 of the 316 quals). UEP, UET and UEG carry the same "listed in [our] CVIG, if not listed … 10 points" prose but their CVIGs have not been read — whether they redirect to sibling qualifications the same way, and what their exemption lists contain, is unknown. Do not extrapolate. Covered by CVIG-WEIGHTING-RECON-01 (rev 2).

Elsewhere-cap enforcement status

  • Enforcement is NOT yet live. importedPoints() now genuinely returns non-zero — the detector is awake, which is what this ADR delivers. But elsewhere_cap is declared and read and never populated: it appears in three places repo-wide, all in qual-tree-types.ts: the field declaration on the ElectiveGroup type, and two reads inside groupStatus — the unresolved-imports indeterminacy check and the elsewhereBreach comparison. Correction 2026-07-31 (authorised by Tim): two → three (C-G3-2, WEIGHTING-RESOLVE-01 Part B). The original count was true of its revision — at HEAD there is one read; the second is added by this lane's own commit, which is why the correction lands in the commit that invalidates it. Cited by symbol, not by line, per CITE-STABLE-ANCHORS-01: the original entry's :187 and :272 had already gone stale against a dirty tree. With no writer, it is null everywhere, elsewhereBreach is unconditionally false, and the guard stays dormant exactly as POINTS-MODEL-03 shipped it. Waking it is a distinct increment (Part E), not a consequence of this decision.
  • The guard sits below an early return (:266-268): a group that is group_constraints_degraded, or that carries no group_constraints, returns totalMet ? 'met' : 'under' and never reaches the cap check. A single-target points qualification therefore never evaluates the cap. Whether this is a real gap is an open question, not a settled defect — the allowance in the source prose is itself expressed against a named group (UEP20218: "Up to 160 weighting points of the general elective units Group A"), so a qual with no sub-groups may carry no open-corpus allowance to enforce. Part E must establish which, by corpus count, before it scopes. Recorded here so the question is canonical rather than discovered at an audit.
  • Rationale capture is forward-compatible with Record; rendering imported_meta in AuditView/Record is a deliberately-deferred CAPTURE→RENDER step, not an omission.

Relates to (evidence)

Byte verification performed at canon-filing time, 2026-07-26, against the working tree at HEAD 504284cf (== origin/main; the whole IMPORTED-UNIT-SELECT lane is working-tree-only, commit is the G5 step):

  • workers/studio-collab-do/src/index.ts (1,075 lines, 39,917 bytes) — NRT_DB binding :24; elective_import wire shape :73; handleElectiveImport :693; Pith title lookup :701-704; structured prereq query :712-720; constant-10 push :727; imported_meta write :729-734; remove-cleanup :662-663; IMPORT_RATIONALE_MAX = 500 :988; payload type :1009-1012.
  • workers/studio-collab-do/wrangler.jsonc (3,431 bytes) — prod NRT_DBrto-nrt-db 1249760d…; env.dev NRT_DBrto-nrt-db-dev b0f0b00e…; dev WORKSPACE_DBrto-workspace-db-staging 25995e53…; no ops-db on either.
  • apps/workspace/app/(workspace)/studio/lib/qual-tree-types.ts (378 lines, 16,343 bytes) — elsewhere_cap :187, imported_meta :192, importedPoints :250, early return :266-268, elsewhereBreach :272.
  • apps/workspace/app/api/studio/units/search/[code]/route.ts — the A2 drill-down, Pith-direct, SELECT-only.

Weighting-source evidence (prod Pith rto-nrt-db 1249760d…, SELECT-only, 2026-07-26): verbatim qualifications.packaging_rules fragments for UEE62120, UEE41520 and UEP20218; counts weighting point = 316, CVIG/Companion-Volume citation = 144, "if not listed" fallback clause = 119, companion-volume/weighting tables in the Pith = 0.

Gate record: outputs/IMPORTED-UNIT-SELECT-brief-rev2-2026-07-24.md and the Part 0/A/B/C/D verdicts (claude/IMPORTED-UNIT-SELECT-part*-verdict-*.md); Part D closed PASS on all 7 ACs 2026-07-26, with AC4 (remove-cleanup) and AC5 (tally) closed by a live import → remove re-walk on the dev twin (D1 trace v7→v8→v9) rather than by end-state query, which cannot prove deletion.

Carried, not closed by this ADR: the CVIG weighting gap and its mitigation ladder (above); Part E (elsewhere-cap enforcement wake, plus the single-target-qual scoping question); AuditView/Record surfacing of imported_meta; the A2 nit — decodeURIComponent is unguarded at search/[code]/route.ts:17, so a malformed percent-escape throws URIError and returns 500 where 400 is correct.