Skip to main content

Repository structure — the platform decomposition

Status: in progress. Ten platform/* layers extracted and enforced; the app tier is still large. Read What is left before assuming a boundary exists that does not.

The problem this addresses

packages/api held 83,460 non-test lines in one package — services, routes, MCP tools, middleware, providers and library code, with no dependency enforcement anywhere in the repo: no may-import table, no boundary test, nothing preventing a module reaching into any other.

The tree

The platform / runtime split at the top level exists because platform is async-networked (round trips, pooled connections, no surviving session state) while the Durable-Object runtime is synchronous-atomic (a single-threaded object; writes without an intervening await commit atomically). Those are different semantics, not different syntaxes — an interface spanning both silently downgrades the guarantee on the atomic side. Putting the boundary in the directory tree makes it visible in every import path.
Note — two views of the same tree. The layer table below is the historical six-layer extraction. A second, coarser grouping has since landed on top of it: the repository is classified into four systems (brain, kernel, runtime, control) plus three non-systems (contract, client, ci), declared in ci/systems.ts and generated into docs/systems/. Each system can be tested alone (pnpm test:brain, test:kernel, test:runtime, test:control). The two views do not conflict — the four-system DAG is the coarse ordering, the table below is the fine one — but ci/systems.ts is the machine-readable authority.

The layering

Read top to bottom. Nothing on a line may import anything on a line below it. Enforced by ci/architecture.test.ts, in the fast CI tier. See Enforcement.

Where the layer names were wrong

The import graph was read before anything moved. It corrected the plan five times. 1. The “Unified Tools Gateway” is not one layer. Its two halves sit at opposite ends of the dependency order, so putting them in one package would make identity and gateway mutually dependent.
  • The catalogue is at the bottom. subject-resolver and account-kit-policy cannot decide anything without knowing which primitives exist, so the catalogue is a dependency of identity. It is already extracted as the dependency-free @usenaive/primitives.
  • The dispatch is at the top. services/approvals.ts — the executor registry that replays an approved action — statically imports twelve product primitives (cards, formation, domain-purchase, verification, connections, browser, trading, compute, phone, email, mobile, webhooks) plus three brain modules. It is imported by seventeen callers.
2. The product primitives are neither inside gateway nor peers of it. They are below the dispatch and above identity/billing. The dispatch depends on them, not the reverse. See The dispatch inversion for why that arrow is a defect and not just a fact. 3. services/browser/safety.ts is not a browser concern. It is an SSRF/private-range guard, and config.ts itself imports it to validate env-supplied hosts. Left where its directory suggested, the environment config depended on a product primitive. It is now platform/kernel/src/net-safety.ts. 4. routes/vault.ts was not only a route. It held the Credential Vault’s envelope crypto, and two non-route callers reached back into an Express router file to get it — including providers/wallet/cdp-credentials.ts, making a platform provider depend on an HTTP surface. Now platform/identity/src/vault-crypto.ts. 5. services/identity.ts is not identity. Despite the name it is the profile product primitive (the end user’s own email and name), it imports credits, and it sits above billing. Moving it on the strength of its name would have put a billing dependency underneath auth. It stays in the app tier.

Is brain separable? Yes.

It was closed except for six edges, from only two modules:
Neither is entangled with brain — both are below it in substance and were merely stored above it. They became platform/ledger and platform/inference. With those out, brain closed with zero escapes and no logic change in any of its 24 modules. Brain is not entangled with identity or billing: it used exactly two things across both (activity-log, credits) and neither imports brain back. Identity has since dropped out entirely — activity-log moved to platform/ledger. Brain does carry a 7-module internal cycle (brainbrain-memorybrain-distillerbrain-promotionbrain-proposalsbrain-writebackmemory-gateway). It predates this work and is preserved unchanged; it is now intra-package, so it is contained rather than spread across a boundary.

Where engine/governor sits

It is already outside packages/ and is already the right shape: a separately deployed, separately licensed sidecar image that the open code reaches only over the PROTOCOLS.md wire contract. Nothing about the decomposition moves it. What the split does clarify is which side of the boundary the client is on. governor-client.ts — the fail-closed wire layer — is in platform/identity, because every caller of it is an authorization decision. A governor unreachable or timing out means 403 enforcement_unavailable; there is no cache and no local fallback evaluation, so a clone of the open repo without the engine image is inert for every gated action, and that property is now a property of a named layer rather than of a directory in a 69k-line package.

routes/ vs mcp/ vs sdk — three surfaces over one service layer

They are an apps/ concern, not a per-package one, and they are not generated today — which is the finding. Measured over eight primitives, the REST route and the MCP tool are thin adapters over one service, and they call the same service functions and the same guard: The duplication is not in the logic — it is in the description. Each primitive’s shape is declared three times: as Express handlers, as zod schemas in an MCP tool, and a third time in the hand-maintained naive-docs/api-reference/openapi.json. They belong together in the app tier (moving one surface into a platform package would bury it away from its twin — which is exactly why middleware/metering.ts was kept next to mcp/guard.ts rather than filed under billing).

Why a generator is NOT the answer here

The obvious next step reads as “generate two of the three from one description”. Reading the surfaces says otherwise, and the finding is worth keeping:
  • An MCP tool’s schema and its handler are one unit. A tool is server.tool(name, description, zodShape, handler), and the handler destructures exactly the shape’s field names, then maps them (snake_case → camelCase) into a service call that differs per tool. Generating the shape without the handler adds a third place that must agree; generating both means generating 277 bespoke handlers. Either way the hand-tuned description prose must survive, so the “registry” ends up holding 277 zod schemas and 277 descriptions — the 5,345 LOC moved, not deleted. (277 tool names are declared under packages/api/src/mcp/tools/; 271 are offered to a session, which is the number the mcp/* pages publish and the number ci/spec-drift-tools pins.)
  • The SDK’s agentTools() is not a tool list. It declares SEVEN tools plus a runtime discovery registry (29 primitives / 123 method entries), so a model gets naive_search_primitives + naive_run_primitive instead of 277 definitions. That is a deliberate compression; projecting endpoints onto it 1:1 would undo it. This is the same shape as the earlier finding that two of the “four hand-synced catalogs” were not mirrors — the marketing catalog had its own legitimate content.
  • @usenaive/primitives is the wrong source anyway. It holds slugs, labels, groups and marketing blurbs. It has no concept of a method or an argument, so driving a generator from it means first hand-writing all 226 method signatures into it — the same work with an extra indirection.
So these are genuinely different products of one idea, not copies. What they share is one fact — whether a tool exists — and that is the fact that had already drifted, in both directions. It is pinned by ci/spec-drift-tools.test.ts rather than generated. The one surface that is a legitimate generation target is the SDK’s resources.ts: 236 near-identical return this.http.*(...) one-liners plus ~20 hand-rolled URLSearchParams blocks. Its source should be openapi.json (612 paths / 719 operations, already gated against the mounted Express app by spec-drift), not the primitive registry. Not attempted here.

The dispatch inversion — DONE

services/approvals.ts statically importing twelve primitives was the one cycle risk the layering could not absorb, and the reason there was no platform/gateway package. It was never papered over with a barrel re-export; it was fixed. The executor registry is now a registration interface (packages/api/src/gateway/executor-registry.ts) rather than a static import table. Each primitive’s actions are registered in packages/api/src/executors/<primitive>.ts, and the composition root packages/api/src/executors/index.ts — called by createApp() — is the one module allowed to name every primitive at once, because naming them is its job. before approvals.ts ─▶ services/cards.ts ─▶ approvals.ts after approvals.ts ─▶ executor-registry.ts ◀─ executors/cards.ts services/approvals.ts now names no product module at all — statically or dynamically. Two things had to change beyond the table itself:
  • sandbox-adapters.ts is gone. Live and sandbox execution were two tables keyed by the same action-type strings with nothing asserting they agreed. They are now one registration per action, so the halves cannot drift. The five actions with no sandbox leg (email.send + the four brain.*) are visibly absent at their registration site rather than a runtime not_configured surprise.
  • The webhook emit is an observer. Three void import("./webhooks.js") calls survived the first pass — a deferred import is the same arrow, just invisible to a reader and to any check that only reads the import block. gateway/approval-observers.ts inverts them: dispatch announces a resolution, the composition root decides that “announce” means “emit a signed webhook”.
The only specifier left below dispatch is @usenaive/platform-ledger/events, a platform layer it is allowed to depend on. A platform/gateway package is now unblocked; moving it is a separate change (the seventeen importers all name ../services/approvals.js today). Enforced by ci/spec-drift-executors.test.ts rule 1, mutation-tested. That rule has to live in a spec-drift file rather than in ci/architecture.test.ts: packages/api is ONE package, so a check that reasons about cross-package specifiers cannot see this edge at all.

Enforcement

ci/architecture.test.ts, blocking in .github/workflows/fast.yml.
  1. Containment — no relative import may escape its own package. A boundary that ../../other/src/thing.js can step over is not a boundary; this rule is what forces every cross-package edge to be a declared specifier, which is what makes rules 2 and 3 mean anything.
  2. May-import — the table above. It also fails on a permission that is granted but never exercised, so it cannot drift into a wish list.
  3. Acyclic — the real cross-package graph, whatever the table permits.
Four containment escapes predate the check and are recorded, with reasons, in CONTAINMENT_EXCEPTIONS. All four are test harnesses; a second assertion fails if any of them stops existing, so the list cannot go stale. Every assertion is mutation-tested. bash ci/architecture-mutations.sh injects one violation per assertion, asserts the suite goes red naming the offender, reverts, and confirms green. Seven mutations, including two meta-guards — without them every toEqual([]) in the file would start passing vacuously the day the file scan breaks.

The spec-drift family

Four files, each covering a different edge, all in the fast tier: The last two are mutation-tested by bash ci/executor-mutations.sh — ten mutations, two of them meta-guards. That script earned its keep on the first run: mutation M1 came back GREEN, because rule 1 tested the specifier text for a services/ segment and approvals.ts imports its siblings as "./cards.js", which has no such segment. The rule was blind to the exact shape of the defect it exists to prevent. It now resolves specifiers against the importing file’s directory.

What is left

packages/api is 85,271 non-test lines across 280 files (services/ 110 files / 45,421 lines; routes/ 88 files / 27,558 lines). Measured at d5123319, not carried forward. And it grew. At the merge base 744df17b the package was 243 non-test files; git diff --numstat 744df17b..HEAD -- packages/api/src, excluding tests, is +20,171 / −186. This increment added roughly twenty thousand non-test lines to the very package this document exists to shrink — the integrations engine, routes/teams.ts, routes/governance.ts and services/deployment-brains.ts are the bulk of it. Stated plainly because a decomposition doc that only ever reports shrinkage is not a measurement; it is an advertisement. The layer count went 6 → 10 in the same period, so extraction did happen; it was outpaced. (The figures this section carried before — 62,222 lines / 234 files — were themselves stale by two increments, and the 73,581 before those. The correction has now been needed three times, which is the argument for deriving it rather than writing it down.) Only services and libs move; routes/ and mcp/tools/ stay in the app tier. No platform/* package contains an Express router, by the argument in three surfaces over one service layer: a route and its MCP twin are one primitive’s two faces, and burying one of them a package away from the other is a loss, not a decomposition. The practical consequence is that services/approvals.ts never blocks a tranche — it is imported by seventeen routes and one service, so a services-only cut is free of it. The next increments, in order. Groups 1–2 have no api-local dependency escaping them and are liftable as-is; 3 is gated on substrate that must move first; 4 must not be touched at all.
  1. The zero-escape primitive singles. Each of these names nothing in packages/api but the layers below it, so each is a self-contained tranche: email + email-triage (926 lines — unblocked by platform/domains, whose departure removed its last sibling edge), phone + phone-messages (1,098), services/conversions/ (8 files, 768), trading (635), verification (493), connections (359), playground (351), queue (208), inbound-webhook-events (178), webhooks (135), task-routing (138), hook-endpoints (113), identity (85).
  2. The substrate two, which unblock the rest of the primitive tier:
    • services/storage.ts (175) — a pure leaf naming only @aws-sdk and crypto. Blocks social, media, voice, browser. Not a mechanical move: it is a near-duplicate of platform/kernel/src/aws-s3.ts (same client bootstrap, same getBucket(), a second S3Client singleton), whose header claims to be the only file touching @aws-sdk/client-s3. Reconciling the two is a real decision and must not ride along inside a move.
    • services/jobs.ts (1,029, 18 importers) — the async job ledger. Needs kernel + billing, so it belongs at the depth of inference and market-data, not inside any one primitive. Blocks voice (7 services + lib/voice/, 1,824), media/images/video (519), search (274), clone, clip.
  3. The orchestration/trigger spinetrigger-router, trigger-subscriptions, event-dispatcher. Still blocked: event-dispatcher and template-apply import routes/tasks.ts, a service→route inversion of the same shape as the vault one.
  4. Do NOT move the Hermes-era orchestration. instance-pool, sidecar-*, orchestration-reconciler, runtime-pool, runtime-meter, runtime-status, vetta-mirror, vetta-runtime, deployments, provisioning, system-staffing, task-completion, lib/sidecar-client.ts. It is the FROZEN legacy runtime: it keeps answering, it takes no additions, and it is not scheduled for removal. Moving it would churn a surface whose whole value right now is that it does not move.
  5. platform/gateway — no longer blocked (the dispatch inversion landed). What remains is the move itself plus deciding the package’s export surface; seventeen callers name ../services/approvals.js today.
  6. Relocate packages/db to platform/db. Contents are already correct; only the path is wrong. Deferred because the blast radius is deploy-time and unverifiable locally: packages/api/Dockerfile, packages/agent-container/Dockerfile, engine/governor/Dockerfile, scripts/dev-start.sh, scripts/dev-setup.sh, scripts/cp-dev.sh, services/boot-migrations.ts (computes a relative path to the migration runner), and the migration docs.
  7. Move test files with their code. Every extracted layer’s tests still live in packages/api, importing the new package specifier — dns-edit.test.ts now names @usenaive/platform-domains/{dns-edit,vercel-dns} from across the boundary. That keeps the api suite’s gate directly comparable across the split (889 tests, unchanged by the domains/market-data extraction), at the cost of tests sitting a package away from what they test. Doing this properly means giving each platform/* package a vitest config and test script first: no platform/* package has one today, so a test moved now would simply stop running.