Skip to content

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:

  1. CommandContext — a typed struct to retire the getDB/setDB singleton and reduce parameter explosion across handlers, commands, and middleware.

  2. Reads-don't-converge principle — whether ADR-0023 should explicitly exempt read-only projections from convergence, to avoid wasted migration effort.

  3. 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.

  4. Static enforcement — import rules for routes/ modules and the T2 mutation matrix assertion that converged entities are not proxy-writable.

  5. Convergence sequence — confirm or adjust the order: onboarding → church-events → capacity writers → notifications.

  6. setDB/getDB retirement — incremental per-entity migration vs aggressive gut of the singleton.

  7. 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)

typescript
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

  • db is required, not optional. Every command handler, every query, and every projection write needs DB access. Making it required removes the guard clause in getDB() and makes the dependency explicit at the type level. The getDB() singleton stays for legacy code during migration but new code must receive CommandContext.db.

  • No env or ctx. ExecutionContext (ctx) is only needed for ctx.waitUntil() — that's an infrastructure concern handled at the fetch/pipeline boundary, not at the handler level. env is only needed for bindings (R2, secrets) which are set up at initialization, not per-command.

  • at is frozen at pipeline entry. All timestamps within a single request derive from this single value, preventing clock-skew between nowISO() calls spread across the handler chain.

Integration path

  1. Add CommandContext type to a new file workers/src/middleware/commandContext.ts.
  2. Build it in the route() helper inside pipeline.ts, right after middleware resolve, alongside the existing MiddlewareContext.
  3. Thread it to RouteHandler as an additional parameter — NOT replacing the existing individual fields yet (backward compatibility).
  4. New command handler factories (createCreateHandler, createUpdateHandler, createDeleteHandler) receive CommandContext as a second argument.
  5. Old handlers migrate one-by-one as each entity converges.

Non-goals

  • CommandContext does NOT carry a full MiddlewareContext (no request, no env, no ctx). Those are pipeline-level concerns, not command-level.
  • CommandContext does NOT replace EventStore injection. The store field in CommandConfig still allows test injection. CommandContext.db is 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)

  1. Remove converged entities from the whitelist. Remove students, roles, classes, class_slots, class_sessions, and nuclei from ALLOWED_TABLES in workers/src/routes/storage.ts.

  2. Add CI check that fails if a converged entity (listed in a new CONVERGED_ENTITIES registry at workers/src/events/convergedEntities.ts) is present in the proxy whitelist. This prevents accidental re-addition.

Short-term (2 weeks)

  1. Deprecate proxy writes entirely. Change the proxy endpoint to reject all non-SELECT statements regardless of role. Only reads pass through.

  2. Hard-deprecation warning header. Add Warning: 299 - "storage/proxy is deprecated, migrate to REST endpoints" to all proxy responses.

End state (4 weeks)

  1. 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:

bash
# 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/*:

TargetAllowed?Notes
./commands.ts or ../*/commands.tsCanonical write entry point
./queries.ts or ../*/queries.tsCanonical read entry point
../middleware/Pipeline, auth, rate-limit
createCommandHandlerRoutes use commands.ts, not raw factories
getDB from ../db/d1Routes never touch DB directly
D1EntityRepoRoutes 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:

typescript
// 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 existing boundary-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:

  1. Onboarding (SDD-147) — ✅ Complete (approveDraft, rejectDraft)
  2. Church-events (SDD-153) — In progress (registerForEvent, approveRegistration)
  3. Capacity writers — After church-events
  4. Notifications (SDD-146) — After capacity

Status per entity

EntityStatusCommand handler?Proxy removal?
STUDENT✅ ConvergedcreateCreateHandler, createUpdateHandler, createDeleteHandlerRemove now
CLASS✅ ConvergedcreateCreateHandler, createUpdateHandler, createDeleteHandlerRemove now
CLASS_SLOT✅ ConvergedVia classSlotServiceRemove now
CLASS_SESSION✅ ConvergedVia classSlotServiceRemove now
ROLE✅ ConvergedcreateCreateHandler, createUpdateHandler, createDeleteHandlerRemove now
NUCLEUS✅ ConvergedVia eventServiceRemove now
USER🟡 PartialcreateCreateHandler, createUpdateHandler (not deactivate/reset-password)N/A (never in proxy)
ATTENDANCE🟡 PartialVia attendanceService (legacy event table)N/A (never in proxy)
CHURCH-EVENT🔴 Not yetRegisterForEvent uses command handler; rest pendingN/A (not in proxy)
NOTIFICATION🔴 Not yetFactory converge plannedN/A (not in proxy)
CAPACITY🔴 Not yetWriters not yet convergedN/A (not in proxy)

Decision 6: setDB/getDB Retirement — Incremental, Not Big-Bang

Incremental, per-entity migration. No dedicated "gut-the-singleton" task.

Rules

  1. New code (new command handlers, new queries, new services) receives CommandContext.db instead of calling getDB().

  2. Old code keeps getDB() until the entity it serves converges. At that point, the handler/service is rewritten to receive CommandContext.db.

  3. 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.

  4. 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 of getDB() call sites increases (to prevent new code from adding to the debt).

Tracking

Add a simple counter in the CI/check step:

bash
# 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

  • handleCreateNotification uses createCreateHandler with 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 markNotificationRead endpoint does NOT need a command handler. It updates a write-audited field in the notification row (or a separate notification_reads table) — but since mark-as-read is idempotent and non-business-critical, a simple D1 UPDATE with a lightweight updated_at column 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.ts already follows a clean factory pattern — just needs the createCommandHandler wrapper added.

Summary of Changes to ADR-0023

AmendmentADR-0023 sectionChange
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§ConsequencesAdd deprecation path: converged entities out, then read-only, then remove
4. Static enforcement§ConsequencesAdd import rules for routes/ + T2 matrix assertion
5. Sequence§GatesStatus table confirmed; no sequence change
6. setDB/getDB§ConsequencesIncremental retirement, no big-bang
7. Notifications§GatesFactory converge confirmed; no carve-out

Consequences

Positive

  • CommandContext removes the singleton pattern from new code, makes dependencies explicit, and freezes at per 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 CommandContext
  • workers/src/events/createCommandHandler.ts — factory pattern for all command handlers
  • workers/src/events/applyEvent.ts — atomic write + audit

Distribuído sob licença MIT.