You are an AI coding agent inside a repo whose backend was assembled with Ctrl AI. Some directories are sealed — content-hashed and tracked. This is the contract for working productively without tripping the locks.
Read
docs/how-ctrl-ai-works.mdfor the full mental model. This doc is the canonical, enforced version of its "For your AI agent" section. The repo's scaffoldedAGENTS.mdis a terse subset of this contract and points back here for the detail; if this doc and the code ever disagree, the code is right and this doc is a bug.
The one rule
parts/** is not yours to edit, and you import a part only through its
seam — parts/<name>/src/index (usually aliased @parts/<name>). Everything
behind that seam is sealed: verified, content-hashed, and done. You don't have
to re-derive auth, billing, email, jobs, or webhooks. They exist; you wire them.
Your job is the product: routes, UI, business logic, your own database tables, and the glue that calls parts through their seams. To change a part, you use a sanctioned verb — never your editor.
Discover before you build — don't re-derive what exists
Before you implement auth, billing, email, jobs, storage, webhooks, or search,
check whether a verified part already covers it. Three machine-readable surfaces
per installed part tell you everything without reading src/:
| File | Answers |
|---|---|
parts/<name>/contract.json |
the typed interface, the env it needs, the routes it declares, the vendor adapters it supports, the tables it owns — the machine-readable manifest |
parts/<name>/seams.md |
the only code you must write to wire it (imperative, with signatures) |
parts/<name>/SPEC.md |
what the part does and its threat model |
The scaffolded AGENTS.md lists the installed parts; ctrl.spec.json records the
capabilities and vendors you were configured with.
The MCP server (@ctrlai/mcp). If the Ctrl AI registry MCP server is
connected, prefer it over guessing — it is the agent-native interface:
resolve_plan(capabilities[])— turn "I need billing + email" into the exact parts / adapters / versions / seams to install (the plan carries the "don't edit interiors" rule with it).search_parts(query),get_contract(part),get_seams(part),get_upgrade_plan(part, from, to)— read the catalog and a part's contract/seams.inspect_repo(),doctor_repo(),conform_repo(),provision_repo()— read your own repo's state: what's installed, whether it's wired and reachable, whether it's built to spec, and the vendor setup it still needs.
If no part covers what you need, build it as product code — but don't re-implement
a capability a part already covers, even via raw fetch (see the hand-roll blind
spot below).
The boundary
There are two halves to the boundary, and both are mechanically enforced.
| Half | Rule | What it protects |
|---|---|---|
| Interior | Don't edit any file under parts/**. |
The attested bytes. An edit changes the part's content hash. |
| Import surface | App code may import only parts/<name>/src/index. |
Your code from coupling to internals that upgrades rewrite. |
What "import only the seam" means precisely
The guard scans every app-side source file (everything outside parts/,
node_modules/, .next/, dist/, build/, out/, coverage/, and .git/)
for import/require specifiers that point into parts/. A specifier is policed
only if it is app-relative — it starts with ./, ../, @/, ~/, or
parts/. Bare npm packages that merely contain parts/ in their path are not
ours to police.
| You wrote | Verdict |
|---|---|
import { sendEmail } from "@parts/email.transactional" |
✅ the seam (resolves to parts/<name>/src/index) |
import { x } from "parts/billing.subscription/src/index" |
✅ the seam, explicit path |
import { x } from "parts/auth.session/src/internal/db" |
❌ interior — only parts/<name>/src/index is the legal surface |
import { x } from "../parts/auth.session/adapters/stripe" |
❌ interior import |
import "@parts/storage.upload/src/index" then mutate the file |
❌ interior edit — hash drift |
The @parts/* alias is wired into your tsconfig.json as
"@parts/*": ["./parts/*/src"], so @parts/<name> resolves to the part's src
(its index). ctrlai wire adds it; never point it deeper than src.
Why the seam and nothing else? The attestation only ever promised the public surface (
src/index). Reaching past it — intosrc/internal/oradapters/— couples you to bytes an upgrade is free to rewrite. The nextctrlai upgradewould silently break your code, and the part did nothing wrong. The seam is the one surface that's stable across versions.
How it's enforced, and what failure looks like
Three commands check the boundary. They run at different moments and answer different questions.
| Command | Runs | Question | Failure mode |
|---|---|---|---|
ctrlai guard |
local pre-commit hook (--staged) |
Do parts/** bytes still match parts.lock, and does app code import only seams? |
Blocks the commit |
ctrlai audit |
CI (push & PR) | Did this repo respect its contracts? (boundary + imports + attestations, plus heuristic route/env/sprawl warnings) | Reddens the build |
ctrlai conform |
CI (push & PR) | Did the agent build what was configured? | Reddens the build |
Note: guard is the local hook; CI runs audit + conform, not guard.
The pre-commit hook calls ctrlai guard --staged, which skips fast unless
something under parts/ (or parts.lock itself) is staged. (Both on-ramps —
create-ctrl-app / ctrlai new and ctrlai init — ship a CI workflow that runs
audit and conform; in an init repo conform is a no-op nudge until a
ctrl.spec.json exists, then it holds the build to it.)
Hash drift → red
If you edit a file inside parts/<name>/, its content hash no longer matches
the pin in parts.lock. guard and audit both report:
parts/<name> was modified (content hash no longer matches parts.lock).
This cannot land quietly and is not downgradeable to a warning — it is a hard FAIL in both the hook and CI. The fix is to undo the edit:
git checkout HEAD -- parts/
…and make your change on your side of the seam instead. If a type error
points inside parts/**, the fix is still on your side — restore the part and
re-read its seams.md.
Illegal deep import → blocked
The legal specifier is the seam — parts/<name>/src/index, or its @parts/<name>
alias. The guard's import scan flags a deep import written as a relative or
bare-parts/ path, e.g. parts/auth.session/src/internal/db or
../parts/auth.session/src/internal/db:
src/app/foo.ts: imports part interior "src/internal/db" — only parts/<name>/src/index is the legal surface (docs/02 §7) (saw "parts/auth.session/src/internal/db")
A deep import via the @parts/* alias can't even resolve. The alias maps
@parts/* → parts/*/src, so @parts/auth.session/internal/db resolves to
parts/auth.session/internal/db/src — which doesn't exist, so TypeScript fails it
as "cannot find module" before the guard ever weighs in. (That's why the guard's
scan only needs to police the relative and bare-parts/ forms.) Either way the
import can't land — re-route it through the part's index.
The guard message you'll see
When the hook fires, the CLI prints:
✋ Part interiors are read-only — edits void the attestation and will fail CI.
Restore: git checkout HEAD -- parts/
Then change YOUR side of the seam instead.
What each part expects from you: parts/<name>/seams.md
Legitimate part changes go through: ctrlai add | ctrlai upgrade | ctrlai eject
The sanctioned verbs — how to actually change a part
When a part needs to change, use one of these. Each one moves the lock and the
spec together, so the build stays green. None of them require — or tolerate —
hand-editing parts/**.
| Verb | Use it to | What it does |
|---|---|---|
ctrlai add <part|pack> |
Install a new capability (or a whole pack). | Vendors the verified part into parts/, pins it in parts.lock, records it in ctrl.spec.json, scaffolds env + seams. |
ctrlai upgrade <part> |
Move to a new version, or flip the vendor. | Re-vendors the part, re-pins the hash, rewrites the env prefill, and hands you only the declared seam changes. |
ctrlai eject <part> |
Take ownership when a part genuinely can't fit. | Moves parts/<name> → ejected/<name>, voids its attestation, drops it from the lock. |
ctrlai remove <part> |
Drop a capability you no longer want. | Deletes parts/<name>, drops the lock pin and the spec entry, so conform stops expecting it. |
Adding
ctrlai add email.transactional # one capability
ctrlai add email.transactional:postmark # …on a specific adapter
ctrlai add saas # a whole pack
add resolves install order, pulls requires dependencies, skips anything
already in parts.lock, and writes ctrl.spec.json (your captured intent). It
then prints the seams to write — the only code you touch — and scaffolds any
required env into .env.example.
The vendor flip
Swapping a vendor at the same version is the canonical one-commit change:
ctrlai upgrade billing.subscription --adapter=paddle
The lockfile entry's adapter, the vendored adapter tree, and one .env.example
line move; the contract didn't move, so there are zero seam changes —
your app code stays put. (Community-tier adapters require --allow-community;
experimental adapters are never installable.)
Eject — the honest exit
If a part truly can't meet a need, don't hack its interior and don't reach around it. Eject it:
ctrlai eject <part> # → ejected/<part>
ctrlai eject <part> --to lib/ # custom destination (must stay in-repo, not under parts/)
Eject is deliberately loud and reviewable. It moves the code to ejected/,
deletes the attestation, removes the lock pin, and tells you, in warnings, that:
- you own that code now — the attestation is void,
conformno longer covers it, and upgrades stop; - you must update imports from
parts/<name>/src/index.jstoejected/<name>/src/index.js; - if the part owned tables, its
_part_migrationsrows are left untouched.
Eject is the legitimate way out — the one that shows up in the diff. It is not a bypass. After eject, the capability is still in your repo and your spec; you've just chosen to maintain it yourself.
Your product's own tables
Your product's own schema (the rows your app owns — boards, posts, orders,
whatever you're building) goes in migrations/NNN-description.sql at the repo
root — not under parts/** (that's the locked boundary). They run
automatically after every part's migration (on npm run dev and
ctrlai migrate), so they can reference part tables like auth_user. Migrations
are forward-only: to change the schema, add a new numbered file; never edit or
delete an applied one.
On-spec ≠ working — keep it built to spec
ctrlai conform is the spec-loop check: it joins ctrl.spec.json (what was
configured) against parts.lock (what's installed) and your app source. It
verifies, per intended capability, that it is:
- installed — present in
parts.lock; - attested — the entry carries an attestation (a
dev:unsigneddev-tier attestation still counts as locked); - on the chosen vendor — the bound adapter matches the vendor you picked;
- not hand-rolled — no app file imports a vendor SDK for a capability the spec pins.
When you change the backend, hold conform green. The most common way to break
it after a vendor flip: the new vendor needs credentials. On-spec is not the
same as working.
ctrlai provision <part> # prints the guided vendor setup with concrete values
ctrlai provision db # a real Postgres on YOUR account, env-wired, migrated
A conform failure tells you exactly which lever to pull — e.g. a capability on
the wrong vendor points you at ctrlai upgrade <cap> --adapter=<the one you configured>; a skipped one at ctrlai add <cap>.
Hand-roll detection is import-based — know its blind spot.
conformflags hand-rolling by spotting an app-side import of a vendor SDK (stripe,resend,better-auth, …) for a capability the spec pins. It catches the common case — an agent re-implementing billing with the Stripe SDK instead of wiring the part. It does not catch a reimplementation written against the vendor's raw HTTP API with no SDK import. So: don't hand-roll a capability a part already covers, even via rawfetch. The lock protects what's inparts/; it can't see infrastructure you build entirely outside it.
At the wall — what to do when the hook blocks you
You will hit a moment where a part doesn't do quite what you need, the hook
blocks your commit, and the fast move looks like editing parts/** or running
git commit --no-verify. Don't.
| Situation | Do this | Not this |
|---|---|---|
| Need to change part behavior | ctrlai upgrade (new version / vendor) |
edit parts/** |
| Part can't meet the need at all | ctrlai eject <part> and own it |
reach around the seam / --no-verify |
| Don't need the capability | ctrlai remove <part> |
delete parts/<name> by hand |
Type error points into parts/** |
git checkout HEAD -- parts/, fix your seam |
"fix" the part interior |
| Vendor flip but it's not working | ctrlai provision <part> for creds |
re-implement against the SDK |
git commit --no-verify skips the local hook — but audit and conform
still run in CI and will redden the build. Bypassing the hook doesn't make the
change legitimate; it just moves the failure downstream and makes it look like
you tried to hide it. The sanctioned verbs are faster than a CI round-trip
anyway, because they move the lock and the spec for you.
What "verified" means — and what it doesn't (yet)
Two promises, kept separate. Don't conflate them.
Legibility — real today. If anything edits a part's interior, the hash
changes and guard (local) and audit (CI) go red. A helpful-but-drifting
agent is caught mechanically; the change cannot land silently. This is the
common failure mode the lock stops cold.
Cryptographic tamper-proofing — roadmap, not today. "Verified" today means
the bytes match the attested content hash and the repo is built to
spec. It does not mean a third party cryptographically signed the part:
attestations are dev:unsigned, and every trust artifact (parts.lock,
ATTESTATION.json, ctrl.spec.json) lives in the repo. A determined writer
with repo access who also rewrites the lock and the attestation can re-bless an
edited part. Closing that gap needs a root of trust outside the repo — real
signing plus server-side hash enforcement plus branch protection — which is the
cross-repo / team direction, not the in-repo layer you're working in.
As an agent, the practical reading is simple: the lock makes your infra
changes legible, not impossible. Work with it — use the verbs, keep
conform green — and every change you make stays a clean, reviewable diff.
Quick reference
# read what each installed part expects of you
parts/<name>/seams.md # the seams to write — the only code you touch
parts/<name>/SPEC.md # what the part does
# change a part (never by hand)
ctrlai add <part|pack> # install a capability
ctrlai upgrade <part> --adapter=<vendor> # version bump or vendor flip
ctrlai eject <part> # take ownership, loudly
ctrlai remove <part> # drop a capability entirely
# make it real / keep it honest
ctrlai provision <part> # vendor credentials (working ≠ on-spec)
ctrlai provision db # a Postgres on your account
ctrlai conform # did you build what was configured?
ctrlai audit # did the repo respect its contracts?
# when blocked
git checkout HEAD -- parts/ # undo an interior edit; fix your seam
The boundary isn't there to slow you down — it's there so the backend stays legible while you move fast on top of it. Wire the seams, build the product, and reach for a verb whenever a part itself needs to change.