Repository structure — the platform decomposition
Status: in progress. Tenplatform/* 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
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 inci/systems.tsand generated intodocs/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 — butci/systems.tsis 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-resolverandaccount-kit-policycannot 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.
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: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 (brain ↔ brain-memory ↔
brain-distiller ↔ brain-promotion ↔ brain-proposals ↔ brain-writeback ↔
memory-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 underpackages/api/src/mcp/tools/; 271 are offered to a session, which is the number themcp/*pages publish and the numberci/spec-drift-toolspins.) - 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 getsnaive_search_primitives+naive_run_primitiveinstead 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/primitivesis 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.
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.tsis 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 fourbrain.*) are visibly absent at their registration site rather than a runtimenot_configuredsurprise.- 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.tsinverts them: dispatch announces a resolution, the composition root decides that “announce” means “emit a signed webhook”.
@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.
- Containment — no relative import may escape its own package. A boundary
that
../../other/src/thing.jscan 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. - 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.
- Acyclic — the real cross-package graph, whatever the table permits.
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.
- The zero-escape primitive singles. Each of these names nothing in
packages/apibut the layers below it, so each is a self-contained tranche:email+email-triage(926 lines — unblocked byplatform/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). - The substrate two, which unblock the rest of the primitive tier:
services/storage.ts(175) — a pure leaf naming only@aws-sdkandcrypto. Blockssocial,media,voice,browser. Not a mechanical move: it is a near-duplicate ofplatform/kernel/src/aws-s3.ts(same client bootstrap, samegetBucket(), a secondS3Clientsingleton), 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 ofinferenceandmarket-data, not inside any one primitive. Blocksvoice(7 services +lib/voice/, 1,824),media/images/video(519),search(274),clone,clip.
- The orchestration/trigger spine —
trigger-router,trigger-subscriptions,event-dispatcher. Still blocked:event-dispatcherandtemplate-applyimportroutes/tasks.ts, a service→route inversion of the same shape as the vault one. - 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. 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.jstoday.- Relocate
packages/dbtoplatform/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. - Move test files with their code. Every extracted layer’s tests still live
in
packages/api, importing the new package specifier —dns-edit.test.tsnow 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 eachplatform/*package a vitest config and test script first: noplatform/*package has one today, so a test moved now would simply stop running.