# Design: service layer — principal-carrying services extracted from the route handlers > Issue #53. Status: **SHIPPED 2026-10-14** (17 slices — the plan below's 11 > grew to 17 when pats/workspace/status-style small surfaces got their own > honest treatment). Every module in `modules/*.ts` is now a thin > authenticate → parse → service → reply adapter, except documented realtime > residue (io emits, dispatch, ctl round-trips — each marked `#53 residue:` > with its reason, several with ordering facts pinned by tests) and > `status.ts`/`health.ts`/`serialize.ts` (kept whole, reasons in-file). > Quartet green on the Node 22.19 floor: 826 tests across the workspace, > every pre-existing route test byte-identical — the regression net held. > AGENTS.md architecture rule 7 now states the contract. Blocks #52 (the > platform assistant calls these services directly). Historical context: > the assistant's first design routed tools through `app.inject()` self-REST > with an internal credential — superseded by this layer. ## 1. Why All business logic lives inside route handlers today (`modules/*.ts`): the #36 personal-scope decisions, visibility filters, credential gates, error mapping. That made the REST surface the only door — fine for a browser, but any in-process consumer (the assistant's tools) would have to self-call HTTP or duplicate the authorization. The settled shape is the standard layered one: **services are the single authorization enforcement point**, HTTP routes thin down to transport adapters, and internal callers call services with an explicit principal. No self-REST, no internal credential, nothing forgeable. ## 2. Conventions (the contract every slice follows) - **Location & shape**: `packages/server/src/services/.ts`. Pure TS, Fastify-free. Dependencies arrive per call (`svcResources.list({ uow }, principal, query)`) — no classes, no singletons, no module-level state (the `realtime/chat.ts` class stays as-is; it is a stateful realtime component, not a domain service). - **Principal**: `Principal = { userId: string; role: 'admin' | 'user' }`, an explicit parameter on every operation that carries authority. Anonymous flows (login/register) take no principal. Type lives in `services/principal.ts`. - **Authz truth moves into services**: ownership/scope decisions (#36 owner-only + 404 existence-hiding, `canManage`, visibility filters) are enforced by the service, thrown as `AppError`. Routes keep `requireAuth`/`requireAdmin` preHandlers (401/403 transport mapping) but re-decide NOTHING — a route passing a principal never re-checks scope. - **Routes thin to**: authenticate (the root hook) → parse (`shared` zod — still the single wire source) → call service → shape reply. Handlers keep no business branches beyond reply shaping. - **Errors**: services throw `AppError`; the Fastify error map is untouched. - **Forbidden imports**: services never import `fastify` types, the socket layer, or `process.env` (config stays in `config.ts`, passed by callers). Realtime side effects (job dispatch, ctl emits) stay at their existing orchestrators (jobs/realtime modules) — services return facts, callers act. - **Testing**: services are unit-testable against the memory driver with no HTTP. The existing route tests (app.inject-based) keep passing UNCHANGED through every slice — they are the regression net; new service tests assert the scope rules directly. ## 3. Slicing (one module per slice, assistant-relevant first) The refactor closes only when every module is extracted — full consistency is the point; there is deliberately no permanent mixed state. Assistant- relevant modules go first so #52 can build on the earliest slices: | order | module (`modules/*.ts`) | notes | | --- | --- | --- | | 1 | resources | the assistant's killer tool surface | | 2 | skills (hub search) | multi-source search, outbound surface #1 | | 3 | machines + inventory/agent-instances reads | read-heavy | | 4 | settings/system (+ system info) | trivial, sets the pattern | | 5 | profiles | reference bundles | | 6 | llm-providers | #51 registry queries | | 7 | credentials | scope shadowing, encryption stays in infra | | 8 | mcp-servers | registry interplay (`mcp/registry.ts` stays) | | 9 | users (+ auth flows) | last-admin rails move with it | | 10 | jobs / runtime-config / client-config | realtime-coupled; services return facts, dispatch stays at routes | | 11 | marketplace residue | 3.5 emitter surfaces | Each slice: extract → routes thin → service unit tests added → route tests green → quartet-tier verify before merge (per-change tier is enough per slice; the push gate covers the whole). ## 4. Risks - **Behavior drift while moving scope checks** — mitigated by the unchanged route tests (they assert 404-hiding and scope outcomes end-to-end). - **Two-door drift AFTER the refactor** (a new route bypassing its service) — convention + review; the wiki index row states the rule in one line. - **Realtime-coupled routes** (config view round-trips, ctl emits) keep their orchestration at the route; only the pure decision logic moves. Slice 10 is sized accordingly.