Code Zero is organized as a dependency-directed monorepo. The core decides what should happen; adapters decide how external systems communicate with it; the runner controls what is allowed to happen to a checkout.
Source-control adapters ─┐
CLI adapter ─────────────┼──> agent runtime ──> runner boundary ──> isolated checkout
packages/api ────────────┘ │
├──> model abstraction ──> provider
└──> shared contracts
apps/web: UI + packages/api's router (oRPC + OpenAPI) + Better Auth ──> database package ──> Postgres
Nuxt marketing ───> public site (no inbound dependencies)
sharedcontains stable data contracts and must not import feature packages.config,models,source-control, andrunnerimplement focused capabilities around shared contracts.agentcomposes policies and state transitions without knowing HTTP or terminal details.cliis an entry-point adapter. It may depend on the runtime, but the runtime must not depend on it.databaseowns the schema, the Drizzle client, and the migrations. It is the only package that talks to Postgres, and it holds no policy.authholds authentication policy and a Better Auth options factory, and reads the store throughdatabase. It does not depend on the runtime.packages/apicomposes the runtime, source-control, models, and config adapters into one router. It may depend on all of them; none of them may depend on it. It does not depend onauth.apps/webis the entry-point adapter and composition root: a Nuxt app whoseserver/directory servespackages/api's router and, through its ownserver/auth.config.tscomposingpackages/auth's options, is the only process that opens the database, throughpackages/database.apps/marketingis a frontend-only Nuxt site with no dependents and no dependencies beyondpackages/i18n. Nothing may import it.
If a change creates a reverse dependency, move the shared contract inward instead of importing an adapter into the runtime.
Only packages/core/runner may execute commands or mutate a target repository at runtime. The boundary is responsible for validating working directories, arguments, timeouts, output limits, and execution mode. A transport handler, source-control adapter, model provider, or state transition must request runner work through typed contracts rather than invoking a shell directly.
observe is the default mode. It can inspect and report but cannot write. Enabling fix requires both an explicit mode and repository policy permission.
RepositoryBoundary holds the filesystem and git behavior shared by every runner; subclasses decide only how a repository command is executed. LocalRunner runs it on the host, ContainerRunner runs it in an ephemeral sandbox. Both report a RunnerDescription that is recorded in evidence, so a claim of isolated verification is auditable rather than assumed.
Git inspection runs in the trusting process because its argv is fixed by the runner package. Repository-supplied commands are the untrusted ones, and those are what isolation moves into a sandbox.
createRunner and runnerOptionsFromPolicy are the only mapping from policy to a concrete boundary. runnerOptionsFromPolicy declares the policy fields it needs structurally, so the runner does not depend on the configuration package. Composition roots call both; nothing else constructs a runner.
Hosted sandboxes follow the same rule. RunnerPool lives in packages/core/runner, accepts credential-free SandboxRequest values, enforces global/repository quotas and lease ceilings before provisioning, and returns only a Runner. Provider credentials are constructor state of a vendor adapter and never enter a request, lease snapshot, agent state, or log. A composition root may schedule and release a lease, but it cannot execute a command itself.
Model transports follow the same adapter rule. packages/core/models owns the AI SDK integrations for OpenAI, Anthropic, Google, AI Gateway, and OpenAI-compatible endpoints behind one ModelProvider contract. Composition roots pass the validated provider policy; credentials come only from fixed provider-specific environment variables, and a custom endpoint can only come from the operator-owned CODE_ZERO_MODEL_BASE_URL environment variable. The agent runtime sees neither SDK objects nor credentials, and all adapters share one structured-output, usage-accounting, timeout, and error-redaction path.
Two of those transports are subscription-based: claude-code and codex-cli drive a vendor CLI that is already logged in on the host, so modelProviderCredentialKind reports subscription and there is no credential for a composition root to supply, redact, or persist. They are the only transports whose SDK spawns a subprocess, which is why three things hold: each stays inert unless its operator flag is exactly true, the vendor SDK is imported lazily so an unused transport costs nothing, and the CLI is configured with its own tools disabled (Claude Code) or read-only with approvals off (Codex) so it cannot read outside the supplied context or edit a checkout behind the runner boundary.
packages/core/models still contains no child_process import of its own — it never spawns anything itself, matching every other package outside packages/core/runner. subscriptionProbeCommand returns the liveness command as a string for zero doctor to run through the runner like every other command, and the CLI process behind a live decide() call is spawned the same way: modelFromEnvironment takes an optional ClaudeCodeProcessSpawner, and packages/cli and packages/api supply one backed by packages/core/runner's spawnManagedProcess — the streaming counterpart to execFileProcessRunner, for a caller that needs a live duplex process instead of one buffered result. Wired that way, the claude-code transport's CLI process is spawned through the same boundary as every repository check, not through the vendor SDK's own default child_process.spawn. codex-cli cannot be closed the same way: ai-sdk-provider-codex-cli exposes no equivalent spawn hook, so that transport's process is spawned by the vendor SDK directly regardless of what a composition root supplies — a vendor limitation, not a choice this codebase makes. The same read-only, no-MCP, approvals-off configuration is still the containment for that one transport.
Both composition roots additionally read config.runner.isolation to decide how they build that spawner, in a local module (subscription-isolation.ts, duplicated in packages/cli and packages/api rather than pulled into a shared package — this decision is composition-root-specific, needing both CodeZeroConfig and an operator environment variable, and neither packages/core/models nor packages/core/runner should own it). On local isolation it wires spawnManagedProcess directly, spawning the CLI on the host, same as before. On container isolation it wires spawnManagedProcess's container option instead: packages/core/runner exports ManagedProcessContainerOptions and containerizedProcessArgv, a docker/podman run invocation deliberately distinct from ContainerRunner.engineArguments() — no repository-checkout volume (the CLI never touches one) and no --network tied to permissions.network (that policy contains an untrusted checkout's commands, not Code Zero's own necessary calls to the vendor API). When container isolation is declared but no CLI container image is configured (CODE_ZERO_CLAUDE_CODE_CONTAINER_IMAGE), the composition root refuses the transport rather than silently falling back to an unisolated host spawn. The refusal is reported to modelFromEnvironment as a subscriptionRefusalReason — a synchronous throw from Code Zero's own code the moment the transport is asked to build a model, never touching the vendor SDK — rather than by turning the enable flag off: the flag also gates fallback selection, so disabling it would have reported the transport as never configured at all and skipped a configured CODE_ZERO_MODEL_FALLBACK_PROVIDER entirely, turning a run that could have degraded into one that fails outright. environmentForModel (used only by zero doctor's diagnostics, which want a plain "not ready" signal rather than this nuance) is the one place that still disables the flag.
A RunnerPool lease is a separate isolation mechanism from config.runner.isolation, and SandboxProvider (vitehub/cloudflare/vercel/custom) returns only the ordinary Runner contract — bounded command execution, never a live process handle — so there is no claude-code spawner that could route the CLI's duplex stream through the same boundary a lease already gives repository commands. runTask (packages/api/src/operations.ts) refuses claude-code outright whenever options.runnerPool is configured, regardless of runner.isolation, the same way it refuses container isolation without an image and for the same reason: a subscriptionRefusalReason, not a disabled flag, so a configured fallback still gets its turn rather than the run failing outright. packages/cli has no RunnerPool concept at all, so this check lives only in packages/api.
Getting an authenticated session into that container took three fixes beyond the mount, each verified against a real docker run rather than assumed: the vendor SDK resolves the CLI to an absolute host path (its bundled native binary, or CODE_ZERO_CLAUDE_CODE_PATH) that does not exist in the container, so the spawner substitutes a bare, image-relative executable name instead (CODE_ZERO_CLAUDE_CODE_CONTAINER_EXECUTABLE, default claude) and passes the vendor's own args through unchanged (they carry no host paths). The CLI's session spans two host locations that are not nested — ~/.claude/ and a sibling file ~/.claude.json — and Docker refuses to bind-mount a file inside an already-:ro directory mount (an OCI runtime restriction, not a policy choice here), so both are mounted as siblings under one synthetic directory with the container's $HOME pointed at it, letting the CLI's own default resolution find both without a CLAUDE_CONFIG_DIR override. And the container runs as the host's UID:GID rather than root: the mounted credential file's 0600 mode plus --cap-drop ALL (which strips even root's permission-bypass capability inside the container) means only a matching UID can satisfy the CLI's own ownership check on it. containerizedProcessArgv also forwards env as -e KEY=VALUE flags now — the container engine's own process env configures its client, not the process it starts, so $HOME and the vendor SDK's other env entries would otherwise never reach the code running inside.
A subscription transport also owns the one failure that repairs itself. A spent usage window is not a permanent error, so ResumingSubscriptionProvider waits for the reset the transport reported and retries, resuming the interrupted session through a SubscriptionSession handle shared by the model factory that writes to it and the error translator that reads it. The wait is bounded by a cumulative budget and an attempt count, and only ever on a reset the transport actually stated, so no run blocks for a duration nobody chose. It sits inside the API-key fallback rather than outside it: the subscription is already paid for. Both wrappers degrade on SubscriptionProviderUnavailableError and nothing else, so a model that merely returned an unusable decision never causes a transport to be swapped or a run to sleep.
packages/api is the library apps/web's server reads from: it composes the agent runtime, source-control adapter, model abstraction, and config into one typed oRPC router (health, tasks.list, tasks.get, tasks.create, approvals.decide, audit.list) and a control-plane operations layer (runTask, TaskScheduler, TaskStore). It holds no HTTP host of its own and does not depend on packages/auth — apps/web/server/ is the only place that constructs a transport handler from it, which keeps the router and its authorization rules identical regardless of which wire protocol serves a given request.
Procedures validate at the boundary with Zod and then delegate; they never invoke a shell or touch a checkout, because runTask is the only place that resolves policy and constructs a runner. A hosted RunnerPool lease is optional and still yields nothing but a Runner. EvlogHandlerPlugin, shared by every transport through one AsyncLocalStorage-backed logger (packages/api/src/orpc/logging.ts), attaches structured request logs; procedures read it defensively (requestLoggerStorage?.getStore()?.set(...)) so router tests that call procedures directly through createRouterClient, without a transport's plugin attached, still pass.
apps/marketing is the public site and holds the weakest position in the graph: no persistence, no credentials, no session, and no runtime-package imports. It links to the dashboard by origin rather than importing anything from it, so the two deploy and fail independently.
It differs from the dashboard in exactly one respect. The dashboard renders with SSR because its session cookie is scoped to its own origin, so the server resolves it directly from the incoming request; the marketing site renders on the server too, but for a different reason — being crawlable is the entire point of it, so it prerenders every route rather than depending on a live request. That gives it a Nitro server, but the only routes on it are the ones @nuxtjs/seo generates — robots.txt and the sitemaps. Anything that needs to read or write state belongs behind the dashboard's server/ routes, not here.
apps/web is the composition root and the only entry-point adapter with HTTP capability: a Nuxt app whose server/ directory hosts
| Route | Purpose |
|---|---|
/rpc/** |
packages/api's router over the typed oRPC RPC transport |
/api/v1/** |
The same router over OpenAPI/REST (OpenAPIHandler); docs at /api/v1/docs, spec at /api/v1/openapi.json |
/api/auth/** |
The Better Auth handler, mounted by @onmax/nuxt-better-auth from server/auth.config.ts |
/rpc/** and /api/v1/** serve the exact same rpcRouter and therefore the exact same authorization rules; only the wire protocol differs. .meta(openapi(...)) metadata on each procedure (method, path, tags) exists purely for the OpenAPI transport and has no effect on the RPC transport — it is attached through a real, regularly-imported function rather than the @orpc/openapi package's alternative bare side-effect import, because Nitro's production bundler tree-shakes an unused side-effect import away even though the package's own sideEffects field marks it as one to keep.
Mutations fail closed behind operator-issued bearer credentials (CODE_ZERO_CONTROL_PLANE_TOKENS, comma-separated name:token pairs). tasks.create additionally requires the target repository path to be one of the configured repositories, so an HTTP caller cannot point a run at an arbitrary server-local path, and the requested execution mode to be granted to the principal by control_plane.modes in the deployment configuration (without one a principal is limited to the non-writable observe and suggest modes). The two grants live in different places on purpose: the mode grants are fixed for the life of the process, while the repositories change while it runs, so the first is a configuration file and the second is a table. Approval decisions record the authenticated principal's name rather than a wire-supplied actor. Reads stay open for the dashboard. This bearer-token scheme is independent of the Better Auth session that protects the dashboard UI itself.
The dashboard follows that work as it happens rather than polling for it. GET /api/events streams the aggregate overview over Server-Sent Events behind the same session the page needs, and pushes whenever a task record is written — which every writer does through one store instance, so a subscriber observes the whole lifecycle and not only the transitions one transport happens to see. Writes are coalesced, so a run recording ten events in a burst sends one overview. Each message is the whole overview rather than a delta: the page renders the aggregate anyway, and a reconnecting client can take the next message as the truth instead of needing a replay log. The notification is process-local, so a second server instance pushes its own writes and not another's; removing that limit needs a shared pub/sub backend, not a change here.
Work reaches the control plane two ways, and both end at the same runTask. POST /webhooks/github is the push-based path, driven by a delivery an operator's provider sends. server/plugins/poller.ts is the pull-based one, for a deployment with no public URL to receive deliveries on: it lists each watched repository's open pull requests on an interval and starts a review for every head commit it has not started one for. The two share the durable DeliveryClaimStore, so a commit reviewed through one is never reviewed again through the other. The poller reads the watched repositories from the store on every pass, so turning polling on for one takes effect without a restart; it watches nothing until an operator configures a repository with polling on. It requests only the mode that repository is configured with — never one that can write — and reaches a checkout only through the path an operator paired with the repository, never one it derives. poll.interval_seconds in the deployment configuration sets the pace. Because it holds an interval in the server process, it belongs to a deployment that stays up rather than a serverless one.
Task persistence is a narrow KeyValueStorage contract adapted over the ViteHub KV Runtime Helper (apps/web/nuxt.config.ts registers vite-hub/nuxt, composing ViteHub into Nuxt's own Nitro build), so the filesystem driver, Cloudflare KV, Deno KV, or Upstash stays interchangeable. Records are redacted on the way in and hold no review input and no checkout path, so task history cannot become a credential or filesystem leak. TaskScheduler bounds concurrency globally and per repository, and rejects work once the queue is exhausted rather than growing without limit.
Transport concerns stop at the route handlers: headers, status mapping, and request objects never reach a runtime package.
Deployment settings — how much work a deployment takes on, in which modes, with which model and which sandbox — have exactly one authority at a time, and the environment decides which one before the process serves a request. No request can move it.
CODE_ZERO_EDITION says which product a deployment is: self-hosted (the default) or cloud, the managed service. It is stated, never inferred: a self-hosted install that happens to run on Cloudflare is still self-hosted. A value the process does not recognise is treated as the locked cloud edition and keeps every run refused until it is corrected, so a typo can never make anything editable.
A self-hosted deployment reads its settings from one source, chosen with CODE_ZERO_SETTINGS_SOURCE:
file(the default) — theexecution,model, andrunnersections of the instance file,config.ymlin the working directory or the pathCODE_ZERO_CONFIGnames (the old namecode-zero.deployment.ymlis still read when it is the only file present). The file is read once at start, the settings page shows it read-only, andsettings.updaterefuses every write on the server whatever the page rendered.dashboard— thedeployment_settingrow in Postgres, edited by an administrator from the settings page throughsettings.updateand exportable as the YAML the file would hold throughsettings.export. The export passes through the environment's secret redaction, and the parser refuses any value shaped like a credential, so it cannot carry a secret. Settings sections still present in the file are ignored and reported as a warning, never merged: a value has one home.
The settings are strict. @code-zero/config parses both sources with one parser that refuses an unknown key, an out-of-range value, a flag-shaped image, or unparseable YAML, and refuses the whole document on any issue rather than defaulting field by field. Invalid settings fail closed: tasks.create, the webhook route, and the poller start no run, and the page lists every issue with its $.path.
Every run is held to them on top of the repository's own .code-zero.yml, stricter winning: execution.max_concurrent_tasks is the scheduler's ceiling, execution.allowed_modes refuses a run before it is recorded, execution.command_timeout_minutes caps how long one repository command may run (and a hosted sandbox lease lasts) — not the whole run, which can execute several commands — execution.max_network is a ceiling a repository can only narrow, model replaces the repository's model choice, and runner forces container isolation with the deployment's engine and image. observe is always allowed, since it is the default every unrequested run starts in and the one mode that never writes.
On the managed cloud the service file is the only source and cannot be switched to the dashboard. The AI provider, the sandbox, the database, and the mail transport are the service's own: customers see only whether each one is configured — no credential, host, or provider name — and the plan's limits. An organization's owners and admins may set stricter limits for their own tasks (concurrency, per-command timeout, allowed modes) in organization_setting; the server refuses any value above the plan and takes the minimum again on every run, so a plan lowered later still wins. Membership and role are looked up in Postgres on every call rather than trusted from the session. A customer's run always belongs to one verified organization — an organization API key's own, or the session's selected organization once the member table confirms it — and work the deployment starts itself (webhook deliveries, polling) belongs to none and runs under the plan.
The settings.* procedures are session-only on both editions: an API key or an operator token can neither read the settings nor change them.
The boundary follows the package rule. packages/core/config parses and applies the settings and knows nothing about where they are stored; packages/database owns the two tables; packages/api authorizes the settings.* procedures and turns the settings into the RunPolicy a task runs under; apps/web/server/utils/settings.ts composes the authority from the environment, the instance file, and the store.
A scoped GitHub issue can become a verified, review-ready pull request without ever widening the runtime's authority. The entry point is the same authenticated webhook path: an issues event is parsed in packages/source-control and produces a task only when repository policy has opted in (issues.enabled) and the issue carries the issues.requireLabel label, so arbitrary issue text can never start a run. The issue's title and body travel to the runtime as bounded, untrusted feedback with trigger issue — data to validate, never instructions — and the run mode comes only from repository policy, never from the wire.
The run itself is the ordinary lifecycle. During planning the model records verifiable acceptance criteria for the issue alongside its plan; the runtime bounds them and carries them into the task result and evidence bundle. Writes still require an explicit write mode, autofix.enabled, confidence, an allowed change-risk class, and — by default, like proactive work — an isolated runner. High-impact changes stop at needs-human exactly as before.
The validation verdict is reported back where the work was requested. Unless issues.validationComment is disabled, a finished run posts one comment on the issue, composed by prepareIssueValidationComment from the persisted evidence alone: confirmed when repository evidence supports the report, not confirmed with every rejection reason when it does not, inconclusive when a human should decide. A run that failed before reaching a verdict posts nothing rather than something misleading, and the GitHubIssueComments adapter can only add a comment — it has no path to label, edit, or close an issue. The comment claims a fix exists only when the run was actually verified.
Publication has a single home: prepareIssuePullRequest in packages/source-control decides whether a finished run has earned a pull request, and composes it when it has. It refuses any run that is not completed, not accepted, not verified by the repository's own checks, changed no files, or proposes a high-impact change, so a pull request can never claim success its evidence does not support — the body is the rendered evidence, including the acceptance criteria. The composition root in apps/web then reads the verified file contents through a read-only runner and hands them to the GitHubPullRequests adapter, which publishes them as a commit on a fresh issues.branchPrefix branch through the Git data API and opens the pull request against the default branch. The branch name is assembled only from operator policy, the issue number, and the task identifier; an existing ref is never force-updated; and the default branch is never committed to. A failed publication never fails the run — the evidence is already persisted — it is reported as the reason no pull request exists.
Authentication follows the same adapter rule at the package level, but not at the process level: Better Auth is mounted in-process by apps/web's /api/auth/** route (server/auth.config.ts), the only route in the app that resolves packages/auth's environment options — including the connection string, through packages/database — and the signing secret (NUXT_BETTER_AUTH_SECRET, required in production; BETTER_AUTH_SECRET only works as a development fallback) and therefore the only part of the app that opens a connection to Postgres, the only database in the repository. Every other route reaches storage exclusively through the KeyValueStorage contract. packages/auth holds the policy: authBetterAuthOptions builds the database, policy, and provider options Better Auth needs, deliberately omitting secret, baseURL, and trustedOrigins so the @onmax/nuxt-better-auth module — which resolves those itself and constructs the actual instance — cannot diverge from it. createAuth, which does build a full standalone instance, remains for callers that own their own secret and origin, such as the Better Auth CLI's schema-generation entry point; nothing in apps/web's request path uses it. packages/auth's ./config and ./access subpaths stay free of database dependencies, so policy and the organization roles can be read without bundling one.
Every plugin is decided by AuthConfig when the instance is built, read from the environment at server start rather than captured at build time. authCapabilities(config) derives a serializable view of the same policy — plain booleans, no credential or origin — which apps/web serves at /api/capabilities and its pages read to decide what to offer, so a policy change needs a restart, not a rebuild. The one build-time input left is whether the hosted-infrastructure client ships, because that decides which code reaches the browser. Each capability that needs something to work refuses to construct without it, naming the missing piece and never a configured value: email verification and password reset need their structural delivery callbacks (bound to packages/mail templates in apps/web), magic links and mail-delivered second-factor codes need theirs, passkeys need a relying party from configuration (never from a request's Origin), and the SCIM entitlement needs its own HMAC secret. Two-factor (issuer "Code Zero", backup codes, trusted devices) and passkeys are on by default because they add no way in, only a stronger one for an account that opts in. Every social provider, magic links, and SSO close sign-up exactly when the password form does. SSO and SCIM are registered only when the composition root passes entitlements: { sso, scim } as true; otherwise their routes do not exist. Organization API keys (cz_ prefix, stored hashed, expiring, rate limited, never a session) require organizations. A key authenticates the control plane on both /rpc/** and /api/v1/** as Authorization: Bearer cz_…: a bearer value that matches no operator token and carries the prefix is verified through the plugin, which enforces expiry, uses and the rate limit, and becomes an apiKey principal named after the key and its organization. New keys carry task: [create], and a key holding it gets only the non-writable observe and suggest modes; the writable modes wait for an organization-scoped run policy. A key never decides an approval, since an approval records a person's review and unblocks writable runs the key could not start. A key that fails verification is refused outright rather than falling back to a session cookie.
Organization roles (owner, admin, member) come from one createAccessControl in packages/auth/src/access.ts: Better Auth's organization statements, which its own endpoints and Better Enrollment and the API-key plugin check, extended with Code Zero's repository, task, policy, billing, member:invite/remove, and apiKey:create/revoke, granted in step so either vocabulary gives the same answer.
Invitations (AUTH_ENABLE_INVITATIONS) are the Better Enrollment plugin, composed by the same factory and under the same rule: packages/auth decides policy and declares delivery structurally, and apps/web's server/auth.config.ts binds packages/mail to it. The plugin's mode is auto-detected from the sign-up policy rather than configured a second time — with AUTH_ENABLE_SIGNUP off every sign-up route is closed and invitations are the only way in, and with it on they degrade to role and organization grants — so the two cannot drift apart. Enabling Better Enrollment fails startup without both a mail transport and a dashboard origin: a private invitation's link is deliberately never returned to whoever created it, so email is the only path the token travels, and an undeliverable invitation reaches nobody at all rather than failing loudly. The organization plugin keeps organizations, members, and roles, but its own invitation flow is not served: its invitation endpoints are removed from the plugin before it is registered, so they are absent from auth.api and answer 404, and an organization invites into itself with a Better Enrollment org-join invite checked against the same roles. One model, one link format, one set of rules. A public invitation still returns its shareable link once from create or resend, while Better Enrollment's sendPublicInvitation callback also sends the inviter a durable copy when it has an attributable email address; headless system invitations without one keep relying on the returned link. Every Better Enrollment link points at one path, /invite?token=, because what the redemption page must render is decided by the auth server when it reads the token; encoding the invitation's kind in the URL would both duplicate that decision and disclose it to anyone the link is forwarded to. That page renders whichever form the server's nextAction names and submits only the fields its requiredFields asks for, so one page covers private and public invitations, app and organization ones, and both modes. It is the one authenticated-surface route with no auth route rule: requiring a session would turn away the signed-out invitee it exists for, and requiring a guest would turn away the signed-in one accepting an organization invitation. Redemption deliberately does not start a session, so a newly created account is handed to the sign-in page with the credentials it just set — for a private invitation the page never learns the full address anyway, since invite.get returns it masked.
The app-wide user.role column belongs to this boundary too, and is distinct from member.role, which scopes a role to a single organization. Better Auth's admin plugin, registered on every deployment, is its authority: it decides which roles administer the deployment (adminRoles, shared with Better Enrollment's invitation management) and owns banning and impersonation. packages/api does not parse the role itself; it asks the admin plugin's userHasPermission whether the account may set roles, and grants the writable modes only on a yes, failing closed when the plugin is absent. Better Auth keeps multiple app-wide roles in one comma-separated string, and only invitation redemption merges into it; any other write replaces the whole set, so code that changes a role has to send the full set or it silently revokes the rest.
The dashboard renders with SSR. The session cookie is scoped to the app's own origin, so the server resolves it directly from the incoming request before the first paint, rather than rendering a signed-out shell that a client-side check then corrects.
Postgres has one owner: packages/database. It declares the tables in Drizzle (src/schema/), opens the connection pool (src/client.ts), and keeps the migrations as reviewable, checked-in SQL under drizzle/. Nothing else constructs a client or names a column.
The split from packages/auth is the same rule applied one level down. Authentication policy and the store it happens to use change for different reasons and are reviewed by different eyes: packages/database knows nothing about sign-in, sessions, or invitations beyond the shape of the rows, and packages/auth states what is allowed and hands drizzleAdapter a client it did not open. The dependency runs one way, and packages/database must never import packages/auth.
Declaring the schema here rather than letting Better Auth's own migration CLI generate it is what makes user, session, account, and verification diffable like any other change. Column names are load-bearing: the Better Auth adapter maps its models by name, so a rename that type-checks can still break sign-in at runtime. Migrations are generated (db:generate) and applied (db:migrate) from the database package alone, and createDatabase is a factory rather than a module-level singleton so importing the package never opens a socket — the composition root owns the pool's lifetime.
DATABASE_URL is the connection string a deployment sets. AUTH_DATABASE_URL is still accepted, because the store used to live inside packages/auth and a deployment configured before the split must not fail to start on upgrade.
The same rule covers the tables a Better Auth plugin brings with it. invite and invite_use (Better Enrollment), two_factor, passkey, api_key (the plugin's apikey model), sso_provider, the SCIM catalog and directory tables, the admin columns on user and session, and the organization tables are declared here, so a deployment that never enables a feature still migrates its tables and simply never writes to them — the alternative is a schema that depends on which flags were set when the migration ran. Column names are checked against each plugin's own schema and against Better Auth's Drizzle generator. api_key.reference_id carries no foreign key because the plugin stores a user or an organization id there depending on the key's configuration. Two of their columns are deliberate exceptions to the repository's usual habits: an accepted invitation is a permanent audit record, so it carries no foreign key to the organization, team, or account it names and denormalizes inviter_name/inviter_email in order to survive their deletion; and invite_use carries only used_at rather than the shared timestamp pair, because a use is a point-in-time fact that must not look rewritable.
The lifecycle is:
discover -> understand -> validate -> plan -> execute -> verify -> review
^ |
└────── repair ──────┘
LifecycleMachine in packages/core/agent holds the transition table and refuses any move it does not define, so an implementation mistake becomes a thrown error rather than an unverified result that looks finished. Notably, executing cannot reach completed without passing through verifying, and planning cannot skip to reviewing. Every non-terminal state can reach failed.
Each stage owns one decision:
- discover collects the checkout, its working-tree or pull-request base-to-head diff, and its native check commands through the runner.
- understand asks the model to interpret untrusted feedback, proactively inspect the complete diff, or interpret an issue task in repository context.
- validate decides the verdict from repository evidence, never from the reviewer's or the model's assertion.
- plan records the plan and resolves authorization. Each refusal is a distinct reportable outcome rather than a silent downgrade.
- execute applies changes restricted to the validated scope, through the runner.
- verify runs the repository's own checks and captures their output.
- review inspects the resulting diff before a run may call itself complete.
Repair re-enters plan with the failing output as context, until agent.maxAttempts is spent.
Proactive review is repository opt-in. Its model decision carries severity, confidence, cited evidence, affected files, and a change-risk classification. The runtime validates the evidence independently, then requires confidence and repository policy to allow the risk class. High-impact changes always stop at needs-human; proactive or autonomous writes use an isolated runner when policy requires it.
Validation lives in packages/core/agent/src/validation.ts and is independent of any provider. It rejects a claim that cites no evidence, names no existing file, or quotes repository content that is not there; it reports a supported but low-confidence claim as inconclusive. Rejection reasons are collected in full rather than short-circuiting on the first, because the report is the product.
TaskResult.verified is derived in exactly one place, at the point a run produces its terminal result: it requires a completed state, an applied change, and every executed check passing. No branch can assert verification it did not earn, which is what makes "a failed verification is never presented as success" a property of the code rather than a convention.
EvidenceBundle and its Markdown renderer live in packages/core/shared because both the source-control adapters and the CLI consume them, and because rendering is a pure function over contracts with no I/O. Terminal states map deterministically onto a provider-neutral run outcome in packages/source-control, which each provider adapter translates into its own status vocabulary — explicitly noting any conclusion the platform cannot express (see docs/source-control-providers.md).
- Put stable input/output types in
sharedonly when multiple packages need them. - Add the capability to the narrowest package.
- Keep external SDK types behind the relevant adapter.
- Add deterministic unit tests, including failure and policy cases.
- Expose it through the CLI or a dedicated transport adapter only after the runtime contract is stable.