ADR-0029: ADR-0023 Amendment — CommandContext, Proxy Governance, and Enforcement
- Status: Proposed
- Date: 2026-07-25
- Deciders: Architecture grilling session (issue #502, wayfinder #499)
Context
ADR-0023 (proposed July 7) set the direction for a server-authoritative model with unified command handlers and atomic event audit. The wayfinder charting (issue #499) surfaced seven concrete amendments needed before migration to enforcement begins.
Issue #502 drills into each:
CommandContext — a typed struct to retire the
getDB/setDBsingleton and reduce parameter explosion across handlers, commands, and middleware.Reads-don't-converge principle — whether ADR-0023 should explicitly exempt read-only projections from convergence, to avoid wasted migration effort.
Storage-proxy governance — the proxy whitelist (6 domain entities already converged or converging) needs a deprecation path: remove entities, CI checks, end state of the proxy endpoint.
Static enforcement — import rules for
routes/modules and the T2 mutation matrix assertion that converged entities are not proxy-writable.Convergence sequence — confirm or adjust the order: onboarding → church-events → capacity writers → notifications.
setDB/getDBretirement — incremental per-entity migration vs aggressive gut of the singleton.Notifications status — confirm factory converge, one audit event per create, no carve-out needed.
Decision 1: CommandContext — Typed Struct, Minimal Shape
Adopt a CommandContext type as a typed struct built once per request in the pipeline and threaded to every handler, command, and query.
Shape (minimal)
interface CommandContext {
db: D1Database; // the D1 binding, always present
actorId: string; // from AuthPrincipal.userId
role: string; // from AuthPrincipal.primaryRole
roles: string[]; // all user roles for RBAC checks
correlationId: string; // request-scoped tracing
at: string; // ISO-8601 timestamp, set once at pipeline entry
}Rationale
dbis required, not optional. Every command handler, every query, and every projection write needs DB access. Making it required removes the guard clause ingetDB()and makes the dependency explicit at the type level. ThegetDB()singleton stays for legacy code during migration but new code must receiveCommandContext.db.No
envorctx. ExecutionContext (ctx) is only needed forctx.waitUntil()— that's an infrastructure concern handled at the fetch/pipeline boundary, not at the handler level.envis only needed for bindings (R2, secrets) which are set up at initialization, not per-command.atis frozen at pipeline entry. All timestamps within a single request derive from this single value, preventing clock-skew betweennowISO()calls spread across the handler chain.
Integration path
- Add
CommandContexttype to a new fileworkers/src/middleware/commandContext.ts. - Build it in the
route()helper insidepipeline.ts, right after middleware resolve, alongside the existingMiddlewareContext. - Thread it to
RouteHandleras an additional parameter — NOT replacing the existing individual fields yet (backward compatibility). - New command handler factories (
createCreateHandler,createUpdateHandler,createDeleteHandler) receiveCommandContextas a second argument. - Old handlers migrate one-by-one as each entity converges.
Non-goals
- CommandContext does NOT carry a full
MiddlewareContext(norequest, noenv, noctx). Those are pipeline-level concerns, not command-level. - CommandContext does NOT replace
EventStoreinjection. Thestorefield inCommandConfigstill allows test injection.CommandContext.dbis the production default.
Decision 2: Reads-Don't-Converge Principle — Explicit ADR-0023 Carve-Out
Amend ADR-0023 to explicitly state that read projections never need migration.
Exact amendment text (to be added to ADR-0023 §Decision)
5. Read projections are exempt from convergence. Every write (create, update, delete) converges through the command handler; every read (list, feed, history, export) remains where it is. The following routes never need a command handler:
- List endpoints (
GET /api/v1/students,GET /api/v1/classes, etc.)- SSE feeds (
GET /api/v1/capacity/feed,GET /api/v1/notifications/feed)- History/audit reads (
GET /api/v1/notifications/history)- Public reads (
GET /api/v1/events,GET /api/v1/events/:eventId)This prevents wasted migration effort on routes that only query current state via
EntityRepository<T>and never touch the event log.
Rationale
- Read projections query D1 tables directly via
EntityRepository<T>. They never write events and never need atomic batch guarantees. - Without this carve-out, every read route would be a candidate for "convergence" — a waste of effort with zero correctness benefit.
- The three-pattern boundaries (ADR-0028) already say "domain reads → EntityRepository." This amendment aligns ADR-0023 with ADR-0028 explicitly.
Enforcement
- No code change needed. This is a documentation carve-out to prevent future wasted effort. The existing boundary lint (
scripts/boundary-lint.sh) and ADR-0028 already enforce the read pattern.
Decision 3: Storage-Proxy Governance — Read-Only Deprecation Path
Immediate actions (this week)
Remove converged entities from the whitelist. Remove
students,roles,classes,class_slots,class_sessions, andnucleifromALLOWED_TABLESinworkers/src/routes/storage.ts.Add CI check that fails if a converged entity (listed in a new
CONVERGED_ENTITIESregistry atworkers/src/events/convergedEntities.ts) is present in the proxy whitelist. This prevents accidental re-addition.
Short-term (2 weeks)
Deprecate proxy writes entirely. Change the proxy endpoint to reject all non-SELECT statements regardless of role. Only reads pass through.
Hard-deprecation warning header. Add
Warning: 299 - "storage/proxy is deprecated, migrate to REST endpoints"to all proxy responses.
End state (4 weeks)
- Remove the storage proxy endpoint. After mobile clients migrate to inline REST (which uses command handlers), the proxy is deleted. Target: next minor release.
Rationale
- The proxy was a phase-2 bridge for the old mobile app to write directly to D1. Every entity in its whitelist now has a REST endpoint with a command handler that produces audit events.
- Keeping the proxy writable means there are two paths to write the same entity — the exact problem ADR-0023 was written to solve.
- Read-only proxy is a softer deprecation step before full removal.
Decision 4: Static Enforcement — Import Rules + T2 Matrix Assertion
Rule A — Import boundary for routes
routes/* modules must import only from commands.ts and queries.ts within each module, never from persistence internals or getDB directly.
Implementation:
# New check in scripts/boundary-lint.sh (or a separate scripts/import-lint.sh)
# Scans every .ts under workers/src/routes/ for direct imports of:
# - "../db/d1" (getDB/setDB)
# - "../storage/" (EntityRepository internals)
# - "../events/applyEvent" (raw applyEvent outside command handlers)
# Fails if any direct import is found outside the allowed paths.Allowed imports for routes/*:
| Target | Allowed? | Notes |
|---|---|---|
./commands.ts or ../*/commands.ts | ✅ | Canonical write entry point |
./queries.ts or ../*/queries.ts | ✅ | Canonical read entry point |
../middleware/ | ✅ | Pipeline, auth, rate-limit |
createCommandHandler | ❌ | Routes use commands.ts, not raw factories |
getDB from ../db/d1 | ❌ | Routes never touch DB directly |
D1EntityRepo | ❌ | Routes never instantiate repos |
Rule B — T2 mutation matrix: whitelist ∩ converged entities = ∅
Extend the T2 mutation matrix (likely in workers/src/__tests__/mutation-matrix.test.ts or similar) to assert:
// For every entity in CONVERGED_ENTITIES:
// - It must NOT appear in the storage proxy ALLOWED_TABLES
// - It must have a command handler registered in router.ts
const converged = new Set(["STUDENT", "ROLE", "CLASS", "CLASS_SLOT", "CLASS_SESSION", "NUCLEUS"]);
const proxyTables = /* read from storage.ts ALLOWED_TABLES */;
const intersection = [...converged].filter(e => proxyTables.has(e.toLowerCase()));
expect(intersection).toEqual([]); // ∅Tooling
- Biome does not have
no-restricted-imports. Use a bash script (like the existingboundary-lint.sh) or add a semgrep rule (.semgrep/exists at root). A bash script is preferred for consistency with the existing tooling.
Decision 5: Convergence Sequence — Confirmed, No Changes
Keep the original sequence from ADR-0023 / the refactor plan:
Onboarding (SDD-147)— ✅ Complete (approveDraft, rejectDraft)- Church-events (SDD-153) — In progress (registerForEvent, approveRegistration)
- Capacity writers — After church-events
- Notifications (SDD-146) — After capacity
Status per entity
| Entity | Status | Command handler? | Proxy removal? |
|---|---|---|---|
| STUDENT | ✅ Converged | createCreateHandler, createUpdateHandler, createDeleteHandler | Remove now |
| CLASS | ✅ Converged | createCreateHandler, createUpdateHandler, createDeleteHandler | Remove now |
| CLASS_SLOT | ✅ Converged | Via classSlotService | Remove now |
| CLASS_SESSION | ✅ Converged | Via classSlotService | Remove now |
| ROLE | ✅ Converged | createCreateHandler, createUpdateHandler, createDeleteHandler | Remove now |
| NUCLEUS | ✅ Converged | Via eventService | Remove now |
| USER | 🟡 Partial | createCreateHandler, createUpdateHandler (not deactivate/reset-password) | N/A (never in proxy) |
| ATTENDANCE | 🟡 Partial | Via attendanceService (legacy event table) | N/A (never in proxy) |
| CHURCH-EVENT | 🔴 Not yet | RegisterForEvent uses command handler; rest pending | N/A (not in proxy) |
| NOTIFICATION | 🔴 Not yet | Factory converge planned | N/A (not in proxy) |
| CAPACITY | 🔴 Not yet | Writers not yet converged | N/A (not in proxy) |
Decision 6: setDB/getDB Retirement — Incremental, Not Big-Bang
Incremental, per-entity migration. No dedicated "gut-the-singleton" task.
Rules
New code (new command handlers, new queries, new services) receives
CommandContext.dbinstead of callinggetDB().Old code keeps
getDB()until the entity it serves converges. At that point, the handler/service is rewritten to receiveCommandContext.db.No big-bang. A single coordinated rewrite of all ~60 call sites is high-risk with no incremental validation. Each entity's convergence naturally pulls its call sites with it.
The singleton stays as fallback.
getDB()remains implemented and exported for the duration of the migration. It is never removed until the last call site is gone. The pre-commit hook will warn when the count ofgetDB()call sites increases (to prevent new code from adding to the debt).
Tracking
Add a simple counter in the CI/check step:
# Count getDB() references in src/ (excluding tests and the definition itself)
COUNT=$(grep -r "getDB()" workers/src/ --include="*.ts" \
| grep -v __tests__ | grep -v "d1.ts" | wc -l)
echo "getDB() call sites remaining: $COUNT"Target: 0 by end of phase 4 (all entities converged).
Decision 7: Notifications — Factory Converge, One Audit Event Per Create
Notifications converge using the standard factory pattern (createCommandHandler), with one audit event per create. No audit carve-out needed.
Details
handleCreateNotificationusescreateCreateHandlerwith entityType"NOTIFICATION".- Payload captures
{ recipientId, title, body, type, priority }. - One event per notification created. No mark-as-read event needed (read status is a projection field, not an audit concern — similar to how attendance mark/present works).
- The
markNotificationReadendpoint does NOT need a command handler. It updates a write-audited field in the notification row (or a separatenotification_readstable) — but sincemark-as-readis idempotent and non-business-critical, a simple D1 UPDATE with a lightweightupdated_atcolumn suffices. This is consistent with the reads-don't-converge principle: the read projection is the source of truth for "has the user seen this."
Why no OSS/Carve-out
- Notification is a simple entity: schema with 6-8 fields, one projection, one action type (CREATE). No conflict resolution, no version chain, no soft-delete.
- Existing code in
routes/notifications.tsalready follows a clean factory pattern — just needs thecreateCommandHandlerwrapper added.
Summary of Changes to ADR-0023
| Amendment | ADR-0023 section | Change |
|---|---|---|
| 1. CommandContext | §Decision (new item 6) | Add typed struct with { db, actorId, role, roles, correlationId, at } |
| 2. Reads-don't-converge | §Decision (new item 5) | Explicitly exempt read projections from convergence |
| 3. Proxy governance | §Consequences | Add deprecation path: converged entities out, then read-only, then remove |
| 4. Static enforcement | §Consequences | Add import rules for routes/ + T2 matrix assertion |
| 5. Sequence | §Gates | Status table confirmed; no sequence change |
6. setDB/getDB | §Consequences | Incremental retirement, no big-bang |
| 7. Notifications | §Gates | Factory converge confirmed; no carve-out |
Consequences
Positive
- CommandContext removes the singleton pattern from new code, makes dependencies explicit, and freezes
atper request — eliminating clock-skew bugs across handler chains. - Reads-don't-converge prevents wasted effort on ~10 route modules that only query state.
- Proxy governance closes the last direct-write backdoor to converged entities, completing the ADR-0023 guarantee that every write goes through a command handler.
- Incremental retirement of
getDB()means no high-risk big-bang rewrite; each entity's natural convergence pulls its call sites cleanly.
Negative
- CommandContext adds a new type that coexists with the old 5-parameter signature during migration. Both must compile, both must be maintained.
- Proxy read-only breaks any mobile client that still writes through the proxy. Requires client-side migration before the deprecation window closes.
- Import lint adds another CI script alongside
boundary-lint.sh.
Neutral
- The six converged entities (STUDENT, CLASS, CLASS_SLOT, CLASS_SESSION, ROLE, NUCLEUS) are ready for proxy removal immediately.
getDB()call sites (~60) will decline naturally over 3-4 sprints as each entity converges.- Notifications convergence is straightforward — the entity is simple and the factory pattern is already documented.
References
- Issue #502 — Grilling: Amend ADR-0023
- Issue #499 — 🧭 Wayfinder: Unify persistence patterns
- ADR-0023 — Server-Authoritative Model (original, being amended)
- ADR-0028 — Persistence Pattern Boundaries (three legitimate strategies)
workers/src/db/d1.ts— getDB/setDB singleton (target of retirement)workers/src/routes/storage.ts— proxy whitelist (target of governance)workers/src/middleware/pipeline.ts— route() helper build site for CommandContextworkers/src/events/createCommandHandler.ts— factory pattern for all command handlersworkers/src/events/applyEvent.ts— atomic write + audit