32 KiB
Server modernization roadmap
Written 2026-07-28, after the budgetService migration and the domain-coherence audit.
Companion docs: migrate.md (service wave list), migration-graph.md (dependency-honest
order, regenerated 2026-07-28), audit_report/domain-coherence-audit.md (the 73 verified
coherence findings — IDs like auth-1 below refer to it), server/src/nest/README.md
(the per-domain migration recipe), server/src/nest/plugins/DI-MIGRATION.md (plugin host,
Option B), guards-audit.md (request-pipeline guard audit, 2026-08-01 — authorization
before the Zod pipe; see the "Request-pipeline guards" stream below).
Status 2026-08-10 — the migration itself is finished. Phases 0–5 all landed:
src/services/was deleted (49652816) andserver/eslint.config.mjsnow errors on any import of it; every wave domain has a Nest module; the body-contract allow-list is empty; the guards capstone (default-denyGlobalAuthGuard+@Public()+ boot ratchet) shipped. The "deliberately late" plugin RPC decorator registry (Option B) and the backupService fold also landed. Still open below: guard slices 1/3 and their parked decisions, the last three tokenization registries, the operational-hardening items (all but the CI de-flake), and the bridge endgame — see the re-checked "Definition of done" at the bottom.
Sequencing principles (learned from what already worked)
- Cross-cutting seams migrate early and cheap — the permissions + auditLog precedent: a small injectable + module-scoped cache + a bridge for legacy callers, swept through consumers. Do this for anything imported by "nearly everything" before the big domains.
- Coherence fixes ride their domain's migration — the exchangeRate→budget precedent:
misplaced code moves into its true domain as part of that domain's fold (parity first,
quirk fixes in a trailing
fix(server)commit), not as a separate big-bang project. Only the fixes that reshape the migration graph itself go first. - Parity is law, ratchets stay on — every migration is byte-identical relocation + DTO adoption (the boot gate enforces allow-list shrinkage), verified by the full suite.
- Wrap globals behind injectables before replacing them — the DatabaseService
precedent: an
@Injectablefacade first (mechanical sweep), transport swap later, invisible behind the facade.
Phase 0 — cross-cutting quick wins (before more domain migrations)
getAppUrl()/getMcpSafeUrl()→src/app-config(done 2026-07-28; findingsauth-2,notifications-1,admin-3,mcp-1— four auditors independently). Two functions, near-zero risk. Deletes the fake→ notificationsedges for auth, oidc, webauthn, plugin-oauth, maps, transit, the MCP issuer andindex.ts. Do first: it shrinks the notifications cluster before Phase 2 migrates it.- Extract addons from adminService (done 2026-07-28; findings
admin-1; permissions-style: DIAddonsServicemethods +addons.bridge.tsfor legacy callers — the module-scoped cache was deliberately skipped: the legacy reads were uncached per-call queries andupdateAddon(still in adminService) has no invalidation hook, so a cache would change admin-toggle visibility; revisit whenupdateAddonmoves in Wave 5).isAddonEnabledwas imported by nearly every domain incl. every.mcp.tswhen:gate. Removed the biggest fan-in from the Wave-5 admin god-service. - Injectable
RealtimeServiceover the ws singleton (done 2026-07-29:@GlobalRealtimeModule, call-time delegation so the 106vi.mock('src/websocket')suites keep intercepting, arity-preserving pass-through; all 17nest/importers + 9 bridges swept; typed against the registry in a follow-up commit) (DatabaseService pattern:@Injectablewrappingbroadcast/broadcastToUser, module-scoped so bridge instances share it). Sweep migratednest/services to inject it instead ofimport { broadcast } from '../../websocket'. This is step 1 of the WebSocket goal — the actual@WebSocketGatewaytransport swap comes much later, invisibly, behind this facade. Do NOT rewrite the ws server itself (auth/rooms/heartbeat/rate-limiting is production-grade; audit says extend, don't rewrite). Pair with the WS event contract registry (see "Tokenization & registries") — the facade is the choke point where the typed event signature lands.
Phase 1 — tripService migration (done 2026-07)
The hub: no blockers left, six bridge consumers die (budget/collab/days/packing/ reservations/vacay bridges become injections). Includes, per the standard recipe:
- MCP: port the trips registrar (+ prompts/resources seams) to
trips.mcp.ts. - DTOs: trips controller adopts shared-schema DTOs, allow-list entries removed.
- Coherence fixes to fold in (trailing fix commits):
trip-1(ICS engine → own module),collections-3(guest-erasure ownership),collections-7(cover-file reclamation helper out of the trips god-file), and the registrar drift findingsmcp-5(update_trip hand-wired rebase ordering) die with the MCP port.
Phase 2 — notifications cluster → notificationService (+ scheduler) (done 2026-08)
Smaller after Phase 0. Unblocks the maps chain and the admin corner.
- Migrate
notifications.ts+notificationService+ prefs + inApp + channelRegistry as one unit (they are one concept in five files — see notifications section of the audit). - Migrated domains then replace the lazy
import('...notificationService')sends with the injected service. - Scheduler — done 2026-08-10:
src/scheduler.tsis deleted; every cron is a provider in its owning domain, registered throughCronRegistrarService(src/nest/scheduling/—@nestjs/schedule+ a NODE_ENV=test gate so the sharedbuildApp()harness never ticks).notifications.bridgeandadmin.bridgedied with it,SchedulerDeps/setSchedulerDepstoo, andnode-cronleftpackage.json(the plugin host's job scheduler moved to thecronpackage). Reminder enable-gates are per-tick now — a settings change takes effect at the next run without a restart.
Phase 3 — geo chain: maps → transit → place → transitItinerary (done 2026-07/08)
- Fold
placeEnrichmentinto places (the helper-fold pattern). - Pull third-party API-key ownership into the maps/integrations domain: authService's
updateMapsKey/updateApiKeys/validateKeysprobes and the duplicated admin-maps-key SQL (auth-4) — maps becomes sole owner of key resolution. airportServicecan go any time; when it does, move the db-boot backfill (a WRITE intoreservation_endpointsat module-eval time, findingplaces-1) into Nest bootstrap.
Phase 4 — the Wave-5 corner, restructured by the audit (done 2026-08-09)
Order inside the corner: atlas → auth-split → admin → oauth (oidc/passkey ride auth).
- atlas first, and it takes
getTravelStatsout of authService with it (the original archetype finding) — theauth → atlasedge is deleted, not bridged. - authService is migrated as a three-way split, not as one domain (
auth-1,auth-3,auth-4,auth-5):nest/auth— credentials, sessions, MFA, tokens, password reset (dependency-clean, crown-jewel code: relocate, don't rework);- a users/profile domain — getCurrentUser/stripUserForClient, avatar (+ multer wiring out of the auth controller), settings, user directory, account lifecycle (deleteAccount + userCleanup seams);
- admin instance settings out of
/api/auth— getAppSettings/updateAppSettings (SMTP, channels, webhooks, the scheduler restart) belong to nest/admin; route parity can keep the URL, ownership moves.getAppConfigbecomes a platform aggregator composing per-domain accessors (auth-5). - MCP-token CRUD (
mcp-3) moves toward the MCP domain; invite-token triplication (auth-7/admin-10) gets one owner.
- admin (already slimmed by Phase 0 addons + packing-template relocation
admin-2/collections-1— templates belong to packing and can move whenever packing-side work happens), then oauth, merging the divergedmcp/oauthProvider.tshalf into the one OAuth domain (mcp-2).
Phase 5 — remainder (done 2026-08-09/10 — src/services/ deleted in 49652816)
- memories/ cluster → journeyService → journeyShareService; collectionsService any time
(its notificationService edge is lazy; fix
collections-2's cross-domain place writes during its fold). - Leaves any time: weatherService, wikiService, airportService (see Phase 3 note).
- backupService last by design (owns the closeDb/reinitialize lifecycle).
Seam-ownership decisions to make (before the code that touches them)
From the audit's "shared concepts owned in halves" theme — each needs a single-owner decision, ideally settled when the neighboring migration opens the files anyway:
- reservation↔budget link lifecycle (
budget-1,budget-2,days-2): today split across budget (syncReservationPricewrites reservations), reservations (syncBudgetOn* + raw budget_items SQL) and days. Likely owner: reservations orchestrates, budget/days expose domain methods. - accommodations (
days-1): SQL+MCP in days, REST in reservations — pick one home. (done 2026-08-10,3d522be0"Give accommodations one home instead of two halves" —nest/accommodations/is the single owner.) - per-domain user erasure (
collections-4,collections-5): domain-owned erasure methods vs the central userCleanupService — pick a convention before the users domain exists (Phase 4 forces this). - todo riding packing's addon + permission (
collections-6): decide if that's a product decision (document it) or an accident (give todo its own addon/permission).
Tokenization & registries — kill the string contracts
Several security- and sync-critical contracts are typo-prone free strings today, even where
a registry already exists but isn't enforced. The repo already has the right precedents:
SCOPES (mcp/scopes.ts, as const + derived Scope type), ADDON_IDS (addons.ts),
plugin KNOWN_PERMISSIONS (plugins/protocol/envelope.ts, shared host+child, sdk-mirrored
with parity tests), the notifications channelRegistry, and the boot-time fail-closed
ratchet (validateBodyContracts). The pattern to replicate: one as const registry, a
derived union type at every call site, and a boot gate that refuses unknown tokens.
Ordered by value:
- WS event contract registry in
@trek/shared(done 2026-07-29: authoritative count is 65 trip + 29 user = 94 events, not 52;TREK_WS_EVENTS+TrekWsPayload<E>; RealtimeService overloads enforce registry keys at compile time (plugin:namespace is the escape hatch); client switches became enumerable lookups with a registry-parity test — 46 handled, 29 handled via dedicated listeners, 19 explicitly ignored; payload schemas are loose scaffolding, tighten opportunistically; 8 payload-drift shapes documented as unions in the schema's DRIFT comments) — Today 52 distinct event names are broadcast server-side but only 33 appear in the client'sremoteEventHandlerswitch, and the budget migration just proved payload-shape drift is real (the MCP{item}payloads were silent client no-ops — audit finding, fixed 2026-07-28). Mechanism:shared/src/realtime/events.schema.tswith event-name constants- a Zod payload schema per event; server
broadcast()/RealtimeServicetyped against it (event name must be a registry key, payload must satisfy its schema), client handler switching exhaustively over the same union (unhandled event = compile-time or test-time error, not silence). Do it with Phase 0'sRealtimeService— the facade is the natural choke point to introduce the typed signature; migrate events opportunistically.
- a Zod payload schema per event; server
- Typed MCP access groups + boot validation (done 2026-07-28:
ScopeGroupderived fromSCOPES,McpAccessGroupRegistryaugmentation types everyaccess.group,trekMcpValidateAccessboot gate refuses unknown group/mode combos incl.writeon read-onlygeo/weather; legacycanRead/canWritenow takeScopeGroup) —access: { group: 'vacay', mode: 'write' }appears ~100+ times across.mcp.tsfiles as free strings. A typo'd group never matches a real scope: scoped tokens get denied (fail closed) butscopes: nullsessions pass (fail open) — silent scope-model drift either way. Mechanism: derive aScopeGroupunion fromSCOPESand type the decorator'saccess.groupagainst it via the samedeclare module '@trek/nest-mcp'augmentation already used forMcpContext(keeps nest-mcp extraction-clean); plus a boot gate in the MCP registry: at discovery time, refuse to boot on anaccess.group/tool-name/whencombination that doesn't resolve againstSCOPES— the body-contract ratchet pattern applied to the MCP surface. The registry the user-facing scopes UI reads (SCOPE_INFO) then serves decorators, the legacycanRead/canWrite, and the OAuth consent screen from one source. - Permission-action tokens —
checkPermission('budget_edit', …)/hasTripPermission('packing_edit', …)at 90+ call sites; the actions are already defined once as data inpermissions.service.ts(key + defaultLevel + allowedLevels) but nothing types the call sites against that table. Mechanism: exportPERMISSION_ACTIONS as const+PermissionActionunion from the permissions domain and typecheckPermission/hasTripPermission/the bridge against it. Candidate for@trek/sharedif the client's permission store uses the same keys (verify — if yes, it's the same single-source rule as the Zod contracts). - Audit-action tokens — 73
writeAudit('admin.addon_update' …)-style call sites with dotted free strings; a typo silently mis-files audit rows forever. Low effort:AUDIT_ACTIONS as constinnest/audit/, typedwriteAudit. Opportunistic — sweep a domain's actions whenever a migration touches it. app_settingskey registry — keys are scattered (partialADMIN_SETTINGS_KEYSin authService, OIDC rows in adminService, 14 inline reads ingetAppConfig) with ownership split across auth/admin (findingsauth-1,auth-5). Fold the registry into the Phase 4 admin-settings relocation: one typed key catalog, per-domain accessors, no rawapp_settingsstring queries outside the owning module.- Verify, don't rebuild, the good ones —
ADDON_IDS(ensure everywhen:gate and client addon check derives from it, no re-typed literals), pluginKNOWN_PERMISSIONS/HOOK_PERMISSION(already parity-tested against plugin-sdk — the model the others should follow), i18n keys (already typed viaTranslationStrings). Small constants worth a home while passing by:trek_sessioncookie name,X-Socket-Id/X-Idempotency-Keyheaders.
Request-pipeline guards — authorization before validation (audited 2026-08-01)
Full audit with call sites, error-string parity landmines, do-not-guard list and
sequencing: guards-audit.md. The finding in one line: authentication is already a
guard (runs before the global ZodValidationPipe), but authorization — trip access 404s,
checkPermission 403s, admin/demo/addon gates — is almost entirely inline in
handlers/services, so unauthorized callers with invalid bodies see Zod 400s before
access errors. Same strangler discipline as the DI migration: each conversion is a
wire-behavior change (400↔403/404 ordering), one focused commit per slice, ordering
pinned by tests.
- Slice 1 — trivial toggle guards (mostly open 2026-08-10):
PluginsEnabledGuard(9 duplicated 503 blocks),DevLinkEnabledGuard,McpEnabledGuard(7 call sites, keep 403), the two drop-inAdminGuardconversions (guards-audit.md §2.2–2.5, §5.1) — all still inline. Two items resolved differently:DemoUploadGuardwas deliberately rejected as a guard — guards run before the multipart parser, so throwing yields ECONNRESET instead of 403 (PROFILE-015); the dedup landed as the sharednest/common/demo-write.tshelper instead. And the three hand-written addon guards collapsed into oneAddonGuard+@RequireAddon(22d178d2). - Slice 2 —
TripAccessGuard+@RequirePermission(landed 2026-08-09,8c0280e2+94221a6d; the decorator shipped as@RequirePermissionwith a@Trip()param decorator, not the planned@RequireTripPermission): 13 controllers onTripAccessGuard, 81@RequirePermissionsites, zero inlinecheckPermissionleft in controllers. Remaining tail (as predicted, the bespoke-string domains): trips (7 bespoke 403 strings), share, trip-invite, feeds (still hand-rolled trip-access SQL, §4.7), and partial conversions in files (guard per-handler but permissions inline — one class-level decorator can't express upload/edit/delete; upload stays off the guard for the multipart reason), places and collab (oldrequireTriphelpers still present). - Slice 3 — per-resource guards (1 of 3 done 2026-08-10):
TripOwnerGuardis done (45611e25-era; applied to the 4 trip-members routes, deliberately skippingPermissionsServiceso admins can't transfer trips). Still open:JourneyRoleGuard(16 inlineNot allowed403s remain; + align its 403-for-invisible enumeration signal to the collections 404-first idiom) andPhotoAccessGuard(memories cross-user access still inlinecanAccessUserPhoto) (§2.6–2.8). - Capstone ratchet — global
APP_GUARDJwtAuthGuard+@Public()decorator (done 2026-08-10,ffb73fac"Turn protection from opt-in into default-deny" +be8081c3MFA guard):GlobalAuthGuard+MfaPolicyGuardasAPP_GUARDs,@Public()/@OptionalAuth()decorators, and the boot-timevalidate-route-guards.tsratchet (PUBLIC_ROUTE_ALLOW_LIST). One deviation from §6's plan: instead of sweeping the per-controller@UseGuards(JwtAuthGuard)sites, the global guard stands down for any route with a declared guard chain (readsGUARDS_METADATA, resolvesreq.userwithout throwing so MFA still sees the caller) — that solves the addon-404-before-401 ordering caveat. MFA middleware folded (one intended behavior change: public/apiroutes no longer 403 MFA-less logged-in users); all four dead middleware files deleted (939452f9,9814480a,be8081c3—src/middleware/now holds onlyglobalMiddleware.ts). - Decisions parked in the audit (still open 2026-08-10): the missing server-side
documentsaddon gate (only ungated addon without a load-bearing parity comment, §4.2 —/api/trips/:tripId/filesstill serves with the addon off); rate-limit unification (§4.4 — half done: one sharedRateLimitService/RateLimitModulenow serves 5 modules, buttransit.mcp.ts, backup,mcp/index.tsand the plugin host still roll their own, and no@RateLimitdecorator exists). Do NOT guard the body-dependent checks (collections owner checks,PUT /trips/:id's body-derived permission key, vacay) or the fail-soft plugin contribution routes — the audit's §3 list is load-bearing.
Continuous streams (not phases)
- DTO ratchet: every migration includes its domain's DTOs; remaining allow-list entries for already-migrated domains are independent hour-scale filler tasks. The boot gate keeps this honest.
- MCP decorator ports: every migration moves its legacy registrar to
<domain>.mcp.ts; registrar-drift findings (mcp-4,mcp-5,mcp-6) die as their domains port. - Quirk-fix trailing commits: parity migration first, verified-defect fixes in a
separate
fix(server)commit (established pattern).
Deliberately late
- Option B decorator RPC registry (DI-MIGRATION.md §3) — done 2026-08-09
(
7b11680cdecorator kit →f72aba0cfull rollout;PluginsModulesplit into four with per-domain coverage gates,6680bc7c). - True
@WebSocketGatewaytransport swap: behind the Phase 0RealtimeServicefacade, whenever — it becomes an implementation detail. index.tsboot dismantling: the scheduler went with Phase 2 (2026-08-10 — Nest lifecycle owns every cron); MCP/ws wiring migrate into Nest lifecycle hooks opportunistically; the dbreinitializeProxy retires only with backupService, last.
Operational hardening — will bite soonest (verified 2026-07-28)
Not architecture, but the things most likely to hurt before the phases above finish:
- De-flake the CI gate. (done — see the Status block below; the checkbox lagged
the work.)
test:coverageis the merge gate (test.yml) and it can exit 1 spuriously: reproduced 2026-07-28 — anEnvironmentTeardownErrorfromvacay.service.test.ts, caused by a fire-and-forget dynamicimport('services/notificationService')resolving after the vitest environment tore down. A flaky main gate trains everyone to re-run, and then real failures slip. Fix the race (await/guard the lazy sends in tests — and note Phase 2's injected notifications removes the pattern entirely), sweep the expected-error stderr noise (SQLITE_ERROR spam makes real failures invisible in logs), and watch the coverage run's cost (~11 min of module imports under istanbul instrumentation and growing with every migration).- Status 2026-07-28 (commits
c7dc38a5+80f39585, test-side only, zerosrc/changes):- Race: defensively closed — global
afterEachdrain intests/setup.tssettles the unawaited import/.thenchains while DB + worker env are alive, plus vacay-stylebeforeAllwarm-ups in the cold suites (unit trips.service, integration vacay/collab/packing/trips). Mechanism verified against vitest 4.1.9 source (the dynamic import's host-RPC fetch is what teardown rejects; warmed URLs skip the RPC); NOT reproduced locally in ~110 stress runs (fast machine — CI's 2-core runner is where the window is wide), so keep an eye on the gate before calling it dead. - Noise: swept — green
test:unitstderr went 8,249 → ~195 lines (tests/setup.console-noise.ts, two exact-pattern filters: migrations duplicate-column replay artifact + the vitest-only airportService backfill require failure). Remaining follow-up if wanted: ~168 stdout[DB] Running migration N/Mlines per test file. - Cost (watch item, unchanged): two clean gate runs at 52s wall on a 16-thread dev box, but cumulative import time ~657s vs ~97s actual test time — instrumented module imports are ~6.6× the tests themselves and scale with migrated-module count, not test count. Phase 2's injected notifications still deletes the send pattern entirely.
- Race: defensively closed — global
- Status 2026-07-28 (commits
- Migration-identity safety.
schema_versionis a single integer and a step's identity is its array position; append-only is enforced only by convention plus a hygiene test, and failed steps log "Non-fatal migration step failed" and continue. The bite: two branches each appending "step N" merge cleanly, and instances migrated at different times silently diverge. Add: a named/checksummed applied-migrations ledger (keep positional order, record identity), a boot-time collision check, and an automatic pre-migration snapshot (backupService already knows how) before any pending step runs. - Swallowed cross-domain side-effect failures. 11
catch → console.errorsites innest/**services alone — mostly the sync seams (reservation↔budget item creation, price sync, booking-import costs). These silently lose user-visible data: a booking saves, its expense doesn't, nobody is told. Define one policy (at minimum: audit-log the failure + emit a client-visible warning event; ideally a small retry queue for the money-adjacent ones) and sweep the seams — pairs naturally with the seam-ownership decisions above, since each seam gets one owner anyway. The client-side rule already exists ("optimistic writes must reconcile — no silent catch"); this is its server twin. - Dependency currency. No automation visible for dependency updates (better-sqlite3 ABI bumps, Nest majors, the Express adapter). Pick a cadence or a bot (Renovate/ dependabot) before the gap gets expensive; the plugin-sdk's standalone lockfile doubles the surface.
Definition of done (so "finished" is checkable, not vibes)
The migration/modernization is complete when all of these hold — each is grep- or gate-verifiable, worth re-checking after every phase:
server/src/services/contains only the deliberate plain-module helpers listed inmigration-graph.md's classification (no domain service files).- Zero
*.bridge.tsfiles remain (bridge deletion notes in each file say when). - The
dbProxy is imported only byDatabaseModule/database.tsinternals and backupService's lifecycle code; every other consumer injectsDatabaseService. index.tsis thin: no hand-booted scheduler/ws/MCP wiring (Nest lifecycle owns them); the db-boot airport backfill is gone (places-1).src/mcp/tools.tsfan-out is empty (registry attach only);src/mcp/resources.tsgone.body-contract-allow-list.tsis empty and deleted (gate removed with it).- Tests: no
vi.mock('src/db/database')outside DatabaseService's own tests — the through-line promise inmigrate.md. - The tokenization boot gates (MCP access groups, WS events) are on and non-empty.
Re-checked 2026-08-10 (after the src/services/ deletion):
- ✅
src/services/gone — deleted outright, plus the ESLint wall. - ✅ No
vi.mock('src/db/database')outside DatabaseService's own tests. - ✅
src/mcp/tools.tsis registry-attach-only (3 live lines); all 24 domains on<domain>.mcp.ts, prompts included. - ✅ Tokenization boot gates on (
nest-mcp-policy.ts,validateBodyContracts,validate-route-guards.ts). - 🟡
body-contract-allow-list.tsis empty (ba7d55f9cleared the last 17) but the file + boot gate still exist — arguably keep the gate as the ratchet. - ✅ (2026-08-10) The scheduler is Nest-owned:
src/scheduler.tsdeleted, every cron a domain provider onCronRegistrarService(src/nest/scheduling/),node-cronout ofpackage.json.index.tsno longer boots any cron and itsshutdown()ridesnestApp.close(). The db-boot airport backfill was already gone (9c46f31c). (2026-08-11: the ws gateway binds insidebuildApp()and /mcp is a Nest controller —index.ts's only MCP touch left is thecloseMcpSessions()housekeeping call inshutdown().) - ✅ (2026-08-11, MCP/OAuth mount migration) Zero
*.bridge.tsfiles remain. The one remaining seam died: /api/health, OAuth discovery, the consent COOP override, /oauth/authorize + /oauth/register (SDK routers viaOauthModule.configure) and /mcp (nest/mcp-transport/) all moved behind the container, sooauth/audit/addons/auth/permissionsbridges became ordinary injections and were deleted, along withmcp/registry-handoff.ts(the transport injectsMcpRegistryService)._shared.ts's impure guards became the injectableMcpToolGuardsService(nest/mcp-shared/). Pre-init Express now carries only global middleware, uploads, the DI-built discovery metadata middleware, static assets, api-docs and the named body-parser wrappers (which keep/mcpbodies raw for the SDK). - 🟡 (historical — the 2026-08-11 morning state this replaced) 5 bridges
remained after the bridge fold, all pinned by the pre-
app.init()mount. The 2026-08-10 endgame executed and then some: the 4 dead ones deleted (8e4261ed);journey.bridge+src/mcp/resources.tsretired with the journey-resource migration;budget.bridgedeleted (UserCleanupService injects BudgetService); the backup-restore / systemNotices in-container edges folded (permissions-cache.ts, threadedaddonEnabled); and the three "verified-permanent" cycle-dodges eliminated by restructuring rather than forwardRef —trips.bridgevia leaf TripMembership reads + prompt relocation,assignments.bridgevia anAssignmentsDomainModulesplit,airtrail.bridgeviaReservationsReadRepository+AirtrailLinkServiceinAirtrailCoreModule.notifications.instance.tsdied with the last of them. - ✅ (2026-08-11)
src/mcp/resources.tsgone — the 4 journey resources are decorator-registered onjourney.mcp.ts;journey.bridgedied with it. - 🟡 (re-checked 2026-08-11, after the mount migration) The
dbproxy is imported outside DatabaseModule by exactly four files:backup.impl.ts(the backup lifecycle the DoD explicitly allows),auth/jwt-verify.ts(the canonical pre-init token verifier the uploads route shares),platform.routes.ts(the guarded /uploads/photos route) andplaces/place-image.ts. Nothing undersrc/mcp/touches it anymore —oauthProvider.tsis the injectableoauth-sdk.provider.ts,_shared.tsis pure, and every bridge is gone.
Keep in mind — ORM / data-layer swap (not scheduled, just recorded)
An ORM move is under consideration but deliberately NOT a roadmap phase. What to remember when the time comes (discussed 2026-07-28):
- The sync/async decision dominates everything. better-sqlite3 being synchronous is
load-bearing:
db.transaction()cannot contain anawait(the budget FX-freeze design exists because of this), controllers return without racing broadcasts, and there are no async gaps inside multi-statement writes. Prisma/TypeORM/MikroORM/Kysely are all Promise-based — adopting one is a rewrite of the concurrency model, not a data-layer swap. Drizzle is the mainstream option that keeps sync semantics over better-sqlite3; its schema-as-code + migration generator would also address the migration-identity item in "Operational hardening". Decide sync-vs-async first, via a short spike with hard criteria: sync preserved? byte-parity strategy for the documented SQL quirks (COALESCE/CASE sentinels, INSERT OR IGNORE, lastInsertRowid)? migration story vs the positionalschema_version? - Do NOT pre-build an abstract DatabaseService or hand-rolled repository interfaces.
Abstraction at the
prepare/get/all/runlevel is the wrong seam (no ORM can be implemented behind it), and interfaces designed before the target is chosen get shaped around today's implementation — every ORM imposes its own repository/entity idioms. Premature abstraction here is negative work. - The migration IS the ORM prep. Each fold consolidates a domain's SQL into one injectable class — that's most of what a repository layer provides. The table-ownership seam decisions (see "Seam-ownership decisions") are the true ORM prerequisites: entities need one owner per table.
- Cheap conventions worth adopting when convenient (no parity risk):
large NEW folds may split into
<domain>.service.ts(logic/permissions/broadcasts) +<domain>.repository.ts(SQL, still over the concreteDatabaseService, byte-identical) — tripService is the natural first candidate; and per-domain typed row types (SettlementRowpattern → arows.tsper domain) as proto-entities. Don't retrofit finished domains until the target is chosen.
Known not-covered (future audits)
- Client-side coherence: this audit was server-only; client CLAUDE.md notes the files bypassing the store→repo→api layering — a parallel audit + ratchet is its own project. The debt is growing: ~137 files at the 2026-07 count, ~190 at a 2026-08-10 recount (components 98, mobile 55, pages 31, plus sync/services/hooks), with no lint/CI ratchet holding the line.
- Low-severity audit findings not scheduled above remain valid opportunistic fixes — see
audit_report/domain-coherence-audit.mdper-domain sections.