Skip to content

Bootstrap and composition

Nine specifications now describe nine mechanisms. Not one of them describes the process that constructs them.

That gap has a precise shape. Milestone 1's acceptance is a single command — agent run "What is 17 multiplied by 23?" — producing six lines of flow, and every document in this corpus describes something that command passes through: the context engine builds its request, the model gateway streams the response, the tool system executes math.calculate, the policy engine authorizes it, the runtime loop drives the turn, and the event log records it. What no document describes is the twenty seconds before any of that: a process starting, reading configuration, deciding which adapter satisfies which port, constructing them in an order that respects seventeen separate startup constraints scattered across those nine specs, and handing the result to a CLI command that must not contain a second runtime loop.

Section 4 of the plan draws the repository tree. Section 5 gives fourteen dependency rules, the fourteenth of which is bootstrap.py by name. Section 17 lists twelve CLI commands. Between them they name every file. They specify the contents of none.

This document specifies that layer: the layout, the configuration object, the composition root, the in-memory tier Milestone 1 runs on, the run dispatcher, the minimal context builder, the CLI, and the four Milestone 0 checks that run against an almost-empty repository. It is the last document written before coding starts, and it is deliberately the least inventive of the nine — almost everything here is a name the plan already chose, given a body.

What this document is responsible for

The rule that governs every choice below: the composition root is the only module that knows both a port and its adapter. Every other module knows one or the other. That is dependency rule 14 restated as a property rather than a prohibition, and it is what makes the rest of the corpus's substitutions — in-memory for Postgres, fake provider for OpenAI, inline dispatcher for the queue — a configuration change rather than a code change.

Three things follow, and they are the spine of this document:

  1. If construction lives in one module, that module must be able to construct everything. So it needs a settings object rich enough to select every adapter, and the settings object is specified here.
  2. If construction lives in one module, the order of construction is a property of that module, not of the components. So the seventeen startup-order constraints the specs state separately are collected here into one sequence.
  3. If construction lives in one module, every entry point shares it. The API, the CLI, and the worker are three main functions over one graph, and the difference between them is which surface they attach, not what they build.

The repository layout

What the plan's tree already fixes

Section 4 names 135 paths. They are not a suggestion — Milestone 0's last acceptance criterion is "No application code exists outside the documented module boundaries", and Milestone 0's import-boundary walk is what turns that sentence into a test. The tree is therefore load-bearing, and this document adds to it rather than redrawing it.

The eight top-level packages under src/agent_core/ and their meanings are unchanged: domain (value objects, Pydantic, no I/O), ports (Protocols over domain), application (services the API and CLI call), runtime (the loop and its supervisors), context, models, tools, policy, adapters, api, cli, evals, plus config.py and bootstrap.py at the package root.

Three places the tree and the specs disagree

Writing this document surfaced three conflicts between the Section 4 tree and the nine specs. All three are resolved here, additively, in the same way the superseded RunRepository methods were: the tree is annotated, not rewritten.

One. runtime/engine.py becomes runtime/loop.py and runtime/executor.py. The tree names one module; ADR-0023 requires two, because the whole point of that decision is that the code which computes an outcome and the code which performs a terminal action are separately reachable — and the structural gate names runtime/executor.py as the sole permitted caller of RunRepository.transition and RunQueue.release. A gate that names a file the tree does not contain is a gate that cannot run. The split is the resolution:

runtime/
  loop.py          -- run_loop; computes a RunOutcome, ends nothing
  executor.py      -- finalize; the only terminal writer
  state_machine.py -- unchanged
  budgets.py       -- unchanged
  retries.py       -- unchanged
  checkpoints.py   -- unchanged
  worker.py        -- unchanged; the claim/lease/heartbeat process
  supervisor.py    -- ADDED; the heartbeat task of ADR-0023

engine.py is not renamed to one of them. It is replaced by both, and the name is retired so that no module can be written against the old boundary without someone noticing.

Two. There is no Anthropic adapter file. adapters/models/ contains fake.py and openai_responses.py. Anthropic and OpenAI are co-equal first adapters — that is a decision on the record — and the contract suite in evaluation-harness.md runs against five adapters. The tree gains:

adapters/models/
  fake.py               -- unchanged; the deterministic provider
  openai_responses.py   -- unchanged
  anthropic_messages.py -- ADDED
  openai_chat.py        -- ADDED; the chat-completions shape
  local_openai.py       -- ADDED; self-hosted, OpenAI-compatible

The last two are named because ADR-0012 requires open and self-hosted models and the contract suite counts five adapters; naming them now costs nothing and prevents the fifth from being written inside the fourth.

Three. The CLI package has no sessions.py. agent session create is a command; cli/ has main.py, chat.py, runs.py, approvals.py, evals.py. The tree gains cli/sessions.py. This is the smallest of the three and the most likely to have been an oversight rather than a decision.

Where the ports live

ports/ has nine modules and the corpus declares thirty-nine ports. The arithmetic is forty-seven Protocol blocks naming forty-three distinct types: four of the blocks re-declare a type an earlier document already declared, and four of the types are the application services of http-api-and-streaming.md, which belong under application/ rather than here. Without an assignment rule, the first implementer invents one, and the second invents a different one. The rule is: a port lives in the module named for the capability it abstracts, not for the component that calls it. The table below assigns all thirty-nine and names fourteen modules where the tree has nine.

ports/models.py
  ModelProvider          model-gateway.md
  ModelRouter            model-gateway.md

ports/repositories.py
  RunRepository          engineering-plan.md, runtime-loop.md
  CheckpointRepository   event-log-and-persistence.md
  ApprovalRepository     policy-and-approvals.md
  UsageRepository        model-gateway.md
  AgentRepository        runtime-loop.md
  BudgetLedger           runtime-loop.md
  PrincipalResolver      runtime-loop.md
  SkillRepository        skills.md

ports/events.py
  EventRepository        engineering-plan.md
  Projection             event-log-and-persistence.md
  Upcaster               event-log-and-persistence.md

ports/dispatch.py
  RunQueue               event-log-and-persistence.md
  RunDispatcher          this document
  CancellationToken      runtime-loop.md

ports/context.py         -- ADDED
  ContextBuilder         engineering-plan.md, context-engine.md
  ContextPlanner         context-engine.md
  TokenEstimator         context-engine.md
  Compactor              context-engine.md

ports/memory.py          -- ADDED
  MemoryRetriever        memory-retrieval-and-ranking.md
  QueryFormer            memory-retrieval-and-ranking.md
  Ranker                 memory-retrieval-and-ranking.md
  EpisodeSearch          memory-retrieval-and-ranking.md
  TraceStore             memory-retrieval-and-ranking.md
  MemoryStore            memory-formation-and-consolidation.md
  MemoryConsolidator     memory-formation-and-consolidation.md
  MemoryCandidateExtractor memory-formation-and-consolidation.md
  Salience               memory-formation-and-consolidation.md
  ConflictResolver       memory-formation-and-consolidation.md

ports/knowledge.py       -- ADDED
  Extractor              knowledge-documents.md
  Chunker                knowledge-documents.md
  KnowledgeStore         knowledge-documents.md

ports/determinism.py     -- ADDED
  Clock                  runtime-loop.md
  IdFactory              runtime-loop.md

ports/credentials.py     -- ADDED
  CredentialResolver     sandbox-isolation.md

ports/tools.py
  Tool                   engineering-plan.md, tool-system.md
  ToolRegistry           tool-system.md
  the MCP ports of tool-system.md

ports/policies.py
  PolicyEngine           engineering-plan.md

ports/artifacts.py
  ArtifactStore          engineering-plan.md
  ArtifactWriter         sandbox-isolation.md
  SkillPackageStore      skills.md

ports/execution.py
  ExecutionEnvironment   engineering-plan.md
  WorkspaceHandle        sandbox-isolation.md

ports/telemetry.py
  the telemetry port

ports/determinism.py is the one grouping that is not obvious, and it is deliberate. Clock and IdFactory have nothing in common as capabilities; they have everything in common as a purpose. They exist so that a run is reproducible, and grouping them by that purpose is what makes the negative rule checkable: no module outside ports/determinism.py and its adapters may read ambient time or generate a random identifier. That is a module-scope static check of the same family as dependency rule 13, and it has somewhere to point only because the two ports share a module.

ports/knowledge.py and ports/credentials.py are the other two groupings that are not obvious, and both are new modules rather than rows in an existing one because nothing already in ports/ is named for what they abstract. Knowledge is memory's sibling and would land in repositories.py under a misreading of the rule as "anything that stores goes with the stores" — KnowledgeStore does store, but extraction and chunking are the capability and the store is downstream of them. CredentialResolver is not policy, because policy decides whether a tool may run and the resolver hands it the secret afterwards, and it is deliberately not execution.py: the one structural thing sandbox-isolation.md says about it is that a tool whose target is the sandbox is handed a resolver that raises for every reference, and a module boundary is the cheapest place for that asymmetry to live.

The rest is the rule applied without argument. SkillRepository joins the repositories for the reason multi-device-and-surfaces.md puts the device registry there. SkillPackageStore and ArtifactWriter both take bytes and hand them back under a key, which is what artifacts.py is named for; ArtifactWriter says as much itself, calling itself deliberately narrower than ArtifactStore. WorkspaceHandle is what ExecutionEnvironment returns.

Two rows still name no type: ports/telemetry.py, which is in the Section 5 tree while no document declares a telemetry Protocol; and the MCP row, whose adapter imports ports and domain and may well implement Tool and ToolRegistry rather than add a third. This is worth naming here and not only there, because evaluation-harness.md gates on a walk of agent_core/ports/ that demands one contract module per Protocol, and a port that exists as a sentence has neither a Protocol nor a contract. The formation row is no longer such a gap: all five memory-formation ports are declared as Protocols, and MemoryStore, MemoryConsolidator, MemoryCandidateExtractor, Salience, and ConflictResolver each have a dedicated contract suite under tests/contract/.

The layout additions in full

src/agent_core/
  runtime/loop.py            replaces engine.py, with executor.py
  runtime/executor.py        replaces engine.py, with loop.py
  runtime/supervisor.py      heartbeat, deadline, cancellation watch
  ports/context.py           context engine ports
  ports/memory.py            memory formation and retrieval ports
  ports/determinism.py       Clock, IdFactory
  ports/knowledge.py         extraction, chunking, the store
  ports/credentials.py       CredentialResolver
  adapters/models/anthropic_messages.py
  adapters/models/openai_chat.py
  adapters/models/local_openai.py
  adapters/memory/           in-memory and Postgres memory stores
  cli/sessions.py            agent session create
tests/
  gates/                     ADDED; the structural gates of Milestone 0
  contract/                  named by the harness; not in the plan tree

tests/gates/ and tests/contract/ are named by evaluation-harness.md's import table and are absent from the Section 4 tree, which lists unit, contract, integration, resilience, security, live, and eval_cases. contract/ is present after all; gates/ is the only genuine addition.

Configuration

The problem the settings object actually has

The corpus now declares 200 configuration knobs across the specifications; the original 106 are joined by Milestone 11's four scheduling-admission ceilings, six definition ceilings, three schedule-worker timing and batch limits, and two reserved-capacity limits, plus Milestone 12's notification claim batch, lease, fallback poll, retry schedule, and terminal expiry, plus Milestone 16's memory lifecycle knobs — five decay time constants, two ranking penalties, two usage deltas, the established-facts switch, the three decay sweep bounds, and the operator trace retention — less the three interactive snapshot caps that leave memory/profiles.yaml for context/plan.yaml, plus Milestone 13's thirteen delegation knobs: the four admission/depth caps, the five default child limits, the three final-synthesis reserves, and the summary byte ceiling, plus the six ranking-weight coefficients the post-Milestone-16 tuning pass moved out of the ranker's literals into memory/profiles.yaml, plus Milestone 24's three device knobs: the invocation timeout, the per-device daily ingest cap, and the poll-back interval, plus ADR-0089's terminal schedule retention days, purge cadence, and purge batch; ADR-0093 adds memory model selection; Milestone 26 adds the two aggregate automatic-email cost allowances, Milestone 29 adds the seven thread-folder knobs and, under ADR-0110, the judgment matcher's switch and probability floor, Milestone 31 adds the unsubscribe grace period, ADR-0123 adds the deferred tool index's item and token caps, ADR-0131 adds how long a tenant HTTP server's MCP discovery is reused and the startup warm-up's handshake timeout, retry interval and attempts, and ADR-0130 adds the five browser-task run limits; ADR-0137 adds the inline tool-output byte ceiling; ADR-0155 adds the title pass's five knobs; ADR-0178 adds the recall candidate-selection mode (relevance, with recent for rollback and frozen controls). The plan names three environment variables: AUTH_MODE, OPENAI_MODEL, and RUN_LIVE_MODEL_TESTS; Milestone 11 adds the default-off schedule API and worker feature flags, and Milestone 13 the default-off delegation flag. Milestones 19 and 23 reuse the schedule pair: the composition root registers schedule.create, schedule.list, schedule.pause, schedule.resume, and schedule.cancel, plus Milestone 23's schedule.update, only when both are enabled, so it cannot create, alter, or reactivate work that no materializer will claim. Those facts are not in tension by accident. Read the inventory and the pattern is obvious: they are almost all tuning values — MAX_COMPACTIONS_PER_STEP = 2, the RRF constant k = 60, the 22,000-token prefix ceiling, the three approval expiry windows, the 8-way parallel-batch cap. Not one of them differs between two deployments of the same revision. They differ between revisions, which is another way of saying they belong in files that get reviewed and committed, not in an environment that gets edited at three in the morning.

The corpus already relies on that. policy_version is {profile_name}@{profile_sha256[:12]}+h{hardline_sha256[:8]} — a policy version is a hash of the file its rules came from, recorded on every decision the engine makes. An environment variable that changed an effective rule would leave the hash untouched and the audit trail lying. The plan says the same thing in prose at Section 15: "Policy rules themselves are version-controlled files, not rows." Generalize it and the rule that sorts all 200 falls out.

A value belongs in the environment if and only if it differs between two deployments of the same revision and cannot be committed. Everything else is a checked-in file. The test is mechanical, and it puts credentials, the database address, and the deployment's identity in the environment, and all 200 tuning knobs in YAML.

The three layers, and why only one of them is a precedence chain

Configuration is assembled in three layers, and the interesting property is that the environment never overrides a file.

  1. Shipped defaults. YAML committed inside the package, next to the module that owns it. This is where all 205 knobs live, at the values the specs state.
  2. The operator overlay. An optional directory, named by AGENT_CONFIG_DIR, whose files are merged over the shipped defaults by top-level key. Section 10.7 requires this — a provider profile is "a plugin the registry loads and the user can override without editing core" — and file-over-file merging keeps the result hashable, diffable, and reviewable, which an environment override would not.
  3. Named interpolation. Inside a YAML value, ${VAR} resolves against Settings.interpolation. The plan already writes this at Section 10.5: model: ${OPENAI_MODEL}. Interpolation happens only where a file explicitly asks for it.

So there is no precedence chain to reason about at a given key. Either the overlay supplies that key or the shipped default does, and either the value contains a ${VAR} or it does not. Policy-semantic documents are the explicit exception: ${VAR} is prohibited anywhere under policy/. The merged policy document is therefore also the resolved effective policy document, and its canonical bytes are what policy_version hashes. Two deployments carrying the same policy overlay cannot run different effective rules under the same audit identifier. Non-policy interpolation, including model selection, is recorded by the resolved model and policy pins that govern the resulting request; it cannot silently alter a policy rule.

The cost is real and is accepted deliberately: changing a knob for one deployment requires either committing a file or adding an interpolation point. There is no escape hatch. That is the point of the design; an escape hatch is exactly the thing that makes a policy_version unfalsifiable.

Where the files live

ADR-0093 sets the production chat default to astra, retaining fake defaults for development. The existing content-addressed production agent version changes with the model policy, so an upgrade does not overwrite an immutable earlier agent version. Existing sessions retain their recorded agent. Maintenance selection is independently configured by the policy-name reference formation.model_policy in memory/profiles.yaml; this selection is included in the configuration inventory. The production credential preflight checks the current chat default's provider.

ADR-0119 lets the owner choose among these at runtime. The composition builds one ModelSettingsCatalog: the chat default and the policies in selectable_chat_policies whose provider holds a credential, and the memory tuple it selected plus any exactly evidenced alternative. The public session service binds a new app chat to a chat-model variant of the default agent, stored under an id derived from the agent and the policy so the deployed agent's latest version never changes; the executor sends the owner's chat effort, and memory formation reads the owner's tuple per extraction. formation.reasoning_effort and selectable_chat_policies are not counted as knobs: a null effort is a meaningful setting that the evidence, not the file, decides, and the selectable list names declared policies the way model_policies does.

The plan set one precedent — src/agent_core/policy/hardline.yaml sits inside the policy package — and this document follows it rather than inventing a top-level config/ directory, which would in any case collide with the config.py module Section 4 already names.

src/agent_core/
  policy/hardline.yaml     the frozen never-bypassable set
  policy/default.yaml      the v0.1 policy profile
  models/policies.yaml     model_policies and provider profiles
  models/catalog.yaml      aliases, limits, context windows, prices
  context/plan.yaml        region caps, reserves, snapshot caps, the 22,000 ceiling
  tools/limits.yaml        registry ceilings, breaker thresholds
  runtime/limits.yaml      leases, sweep cadences, priority classes
  memory/profiles.yaml     RRF k, decay and usage knobs

The two under policy/ are already named by policy-and-approvals.md. The other six are additions, and each one is a home for knobs a spec has already fixed a value for — none of them introduces a knob that does not already exist.

The count is executable rather than prose. SHIPPED_KNOB_PATHS in agent_core.config names every operator-reviewable dotted path, and a static test resolves every path from its shipped YAML document, rejects null values, and asserts the total is 205. Schema versions, profile names, rule identifiers, model-catalog records, conditions, and frozen hardline predicates are metadata or invariants rather than knobs and are not counted.

File Knobs
policy/default.yaml 23
models/policies.yaml 4
context/plan.yaml 30
tools/limits.yaml 25
runtime/limits.yaml 65
memory/profiles.yaml 44
folders/profiles.yaml 9
titles/profiles.yaml 5
Total 205

Milestone 16 wires memory/profiles.yaml into the composition root, which is where its knob count moves from seventeen to twenty-eight: the memory lifecycle knobs — decay time constants, ranking penalties, usage deltas, the decay sweep's bounds, the established-facts switch, and the operator trace retention — arrive, and the interactive snapshot caps leave, because the planner already reads that ceiling from context/plan.yaml and two sources for one number is a bug waiting for an overlay. The six coefficients of the ranking function's additive terms arrive after Milestone 16, taking the document to thirty-four: they were literals inside the ranker, and the lifecycle specification's own rule — hand weights tuned against the evaluation harness — wants them where a tuning pass is a reviewed configuration diff. The session idle boundary is deliberately not a knob: memory-evaluation-and-lifecycle.md keeps it a constant so that two beliefs formed under the same recorded formation policy stay comparable.

Milestone 24 adds the three device knobs device-channel-and-sms.md fixes values for: the invocation timeout the push-wake channel waits out, the per-device daily ingest cap, and the interval between poll-backs. They are knobs rather than constants because the timeout and the cap are the two numbers an operator retunes when a phone answers slowly or a chatty channel floods triage.

Five required operational defaults had no numeric value in the corpus. ADR-0036 sets the initial values: a 4 MiB global tool-output ceiling, a 30-second worker lease, and default run caps of 32 steps, 16 model calls, and 32 tool calls. The values are versioned alongside the 101 values already fixed by the specs, so a later evidence-backed change is an ordinary reviewed configuration diff rather than an environment override.

hardline.yaml is the one file the overlay may not touch. Section 15 requires the hardline set to be frozen at load "so no configuration or in-process code can disable them", and an overlay is configuration. Attempting to overlay it is a startup error, not a silent no-op.

The settings object

@dataclass(frozen=True)
class Settings:
    """The environment layer. Nothing here is a tuning knob."""

    database_url: str
    deployment_mode: DeploymentMode      # development | production
    auth_mode: AuthMode                  # dev | token
    auth_token: SecretStr | None
    sandbox: SandboxMechanism            # microvm | gvisor | docker | fake
    config_dir: Path | None              # operator overlay directory
    credentials: Mapping[str, SecretStr]
    interpolation: Mapping[str, str]     # what ${VAR} resolves to
    schedule_api_enabled: bool           # deployment feature flag, default false
    schedule_worker_enabled: bool        # deployment feature flag, default false

These eight fields are the core deployment boundary. Later rollout features add typed mode settings and, where activation requires reviewed evidence, a path to that evidence artifact, plus the two schedule feature flags above; these are deployment controls rather than tuning knobs and therefore remain in Settings. credentials is keyed by provider profile name rather than by a fixed set of provider fields, because Section 10.7 makes providers plugins; adding a provider must not require editing this class. interpolation is the allow-list of names a YAML file may reference, so a typo in a config file fails at load with the offending name rather than silently producing an empty model identifier.

sandbox carries four values rather than three. fake is added by sandbox-isolation.md as a production adapter in the sense the plan uses for the in-memory repositories, a real implementation of the port that runs the contract suite unchanged (sandbox-isolation.md:1269), and it is what lets the whole system be exercised without a hypervisor. Startup check 4 below refuses it in production beside docker.

SecretStr is the one place a Pydantic type is welcome in a value object. Dependency rule 6 keeps Pydantic-only behaviour out of the domain, and Settings is not a domain model — it is the boundary where credentials enter the process, and a type whose __repr__ cannot leak is worth having exactly there. Coding standard 9 ("do not log full prompts or secrets") is then a property of the type rather than a rule people have to remember.

.env.example

The plan makes this file a definition-of-done item for every milestone: "New configuration appears in .env.example." The requirement stands literally for every operator-facing environment key; a committed default is not a substitute. Every newly accepted environment key must be added here in the same change, with a safe example and enough context to distinguish required, optional, and role-specific values. File-backed schema entries remain version-controlled configuration rather than environment overrides and are documented in their owning committed YAML.

# Required in every deployment.
DATABASE_URL=postgresql+asyncpg://agent:agent@localhost:5432/agent
DEPLOYMENT_MODE=development

# Authentication. Section 16: token mode requires AUTH_TOKEN, and
# startup fails outside development if it is missing.
AUTH_MODE=dev
AUTH_TOKEN=

# One per enabled provider profile. Never a real value in this file.
VEETBOT_OPENAI_KEY=
ANTHROPIC_API_KEY=

# Interpolated into models/policies.yaml as ${OPENAI_MODEL}.
OPENAI_MODEL=

# docker is refused when DEPLOYMENT_MODE=production (ADR-0008).
SANDBOX_MECHANISM=docker

# Optional overlay directory merged over the shipped YAML defaults.
# Unset means run exactly as committed.
AGENT_CONFIG_DIR=

# Scheduled task control plane and materializer. Both default off and
# production activation requires changing them together. New sessions expose
# all five schedule.* model tools only while both are enabled.
AGENT_SCHEDULE_API_ENABLED=0
AGENT_SCHEDULE_WORKER_ENABLED=0

# Test-only. 1 enables the live-provider job (Section 20.4).
RUN_LIVE_MODEL_TESTS=

Twelve canonical names for ten fields, which is not a discrepancy. VEETBOT_OPENAI_KEY and ANTHROPIC_API_KEY both populate the credentials mapping, which is keyed by provider profile name and so grows a name per profile without growing a field. OPENAI_API_KEY remains an accepted compatibility alias for the OpenAI profile; VEETBOT_OPENAI_KEY wins when both are present. OPENAI_MODEL populates interpolation. RUN_LIVE_MODEL_TESTS populates no Settings field at all: it is read by the test harness to enable the live-provider job, and it is listed here because the definition-of-done rule is that no configuration is undocumented, not that every name is a field. The remaining eight names map one-to-one onto the eight scalar fields.

DATABASE_URL does not appear anywhere in the plan. The plan names PostgreSQL as the source of truth in Section 2, gives the schema in Section 14, and requires migrations that "upgrade from a clean database" — and never says how the process finds the database. This is an omission rather than a decision, and the variable is added here under the name every SQLAlchemy deployment already uses.

Validation happens before construction

config.py exposes one function, load_settings(), and it either returns a valid Settings or raises. It does not warn, and it does not fall back. Six checks run there, before any adapter exists:

  1. Every required variable is present and parses. A missing DATABASE_URL is an error, not an empty string.
  2. auth_mode == "token" implies auth_token is not None. Section 16: "In non-development mode, startup must fail if authentication is not configured."
  3. deployment_mode == "production" implies auth_mode != "dev". The dev mode is documented as localhost-only, and localhost-only is not a production posture.
  4. deployment_mode == "production" implies sandbox is neither docker nor fake. ADR-0008: "Production startup must refuse to run untrusted code under the development fallback." fake is behind the same check because it executes nothing (sandbox-isolation.md:1622), and a mechanism that executes nothing isolates less than the fallback this rule was written for.
  5. config_dir, if set, exists and contains only files that mirror a shipped default name. An overlay file with no counterpart is a typo, and a typo in a config filename is otherwise invisible.
  6. Every ${VAR} reachable in the merged YAML has a key in interpolation.

None of these is a warning, and none of them is deferred to first use. A process that cannot serve requests correctly should fail while a deployment system is still watching, not on the first request an hour later.

The composition root

One module, one function

Dependency rule 14 is "Use explicit dependency construction in bootstrap.py; do not add a dependency-injection framework", and ADR-0001 turns it into a check: "a dependency-manifest check against a denylist plus an assertion that construction happens in one module." The assertion needs something to assert against, and this is it.

@asynccontextmanager
async def build(
    settings: Settings,
    role: DeploymentRole,
) -> AsyncIterator[Composition]:
    """The only function in the process that names an adapter."""
@dataclass(frozen=True)
class Composition:
    settings: Settings
    ruleset: LoadedRuleset       # frozen; carries policy_version
    services: ApplicationServices
    background: Background | None  # None when role is "api"
@dataclass(frozen=True)
class ApplicationServices:
    sessions: SessionService
    runs: RunService
    approvals: ApprovalService
    artifacts: ArtifactService

build is an async context manager rather than a plain function because the things it constructs — a connection pool, HTTP clients, an execution-service channel — have to be closed, and the process that owns construction is the only one that can honestly own teardown. Every entry point is therefore a thin async with build(...) as composition: around a surface.

Composition exposes application services only. No adapter, no repository, no session factory is reachable from it. This is deliberate and it is checkable: an entry point that could reach a RunRepository could transition a run without going through RunService, and runtime-loop.md reserves RunRepository.transition and RunQueue.release to runtime/executor.py alone. Narrowing the return type is how that reservation survives contact with a second entry point.

The five phases

The seventeen startup-order constraints stated across the corpus are not seventeen independent orderings. They collapse into five phases, and the phases are ordered by what each one is allowed to touch: phase 1 touches nothing, phase 2 touches nothing external, phase 3 opens resources, phase 4 reads files and writes one event, phase 5 constructs.

Phase 1 — refuse. load_settings() has already run; this phase makes the decisions that can be made from configuration alone, before anything is built. Authentication configured or fail. Production refuses the development sandbox. Production refuses any policy profile, principal, or tenant name beginning with eval. or tenant_eval. The configured context plan's prefix classes sum to at most 22,000 tokens. Nothing has been constructed yet, so nothing has to be torn down, which is the entire reason these checks are first rather than convenient.

Phase 2 — determinism. Clock and IdFactory, and nothing before them. evaluation-harness.md places these in Milestone 1 "before anything depends on ambient time", and the composition root is the first thing that would: the policy.profile.loaded event emitted in phase 4 has a timestamp and an identifier. Constructing the determinism ports first means the negative rule — no module outside ports/determinism.py and its adapters reads ambient time — holds for startup too, not only for runs.

Phase 3 — resources. The async engine and the async_sessionmaker, the provider HTTP clients, the artifact-store handle, the execution-service channel. The session factory is constructed here; a session is not. Dependency rule 13 forbids global singleton database sessions, and the distinction the rule turns on is exactly this one — a factory in the composition is fine, a live AsyncSession in it is the bug the rule names. Section 2.2 states the unit-of-work rule the factory exists to serve: "Each request, worker operation, or parallel tool invocation must receive its own unit of work and database session."

This phase also asserts the schema revision. It does not migrate. What it compares against is specified in event-log-and-persistence.md: an EXPECTED_REVISION constant in the persistence adapter, read against the single row of alembic_version, rather than a head computed from the migrations directory at runtime.

Phase 4 — freeze. Everything versioned is loaded, hashed, and made immutable, in this order:

  1. hardline.yaml, first and un-overlayable, because Section 15 requires the never-bypassable set to be frozen "so no configuration or in-process code can disable them" — and anything loaded before it could, in principle, be what disables it.
  2. The policy profiles, compiled once and frozen, yielding LoadedRuleset and policy_version. policy.profile.loaded is appended here; this is the one write the composition root performs.
  3. Provider profiles and the model catalog, so that "adding a model is configuration, not code" is true of a running process.
  4. The tool registry. Reserved-domain enforcement (mcp and device are a startup error for a builtin) and forced trust labels (MCP, DEVICE, and SANDBOX sources are overwritten to EXTERNAL_UNTRUSTED) both happen at registration, which means they happen here or they do not happen at all.
  5. The compaction prompt, whose version is recorded on every checkpoint it produces and therefore has to be pinned before a checkpoint can exist.

MCP capability negotiation — declining sampling/createMessage and roots — joins this phase in Milestone 6, when servers are first connected. Its placement is the same: negotiation is registration, and registration is phase 4.

Phase 5 — wire. Adapters are selected from settings, application services are constructed over them, and the role's background machinery is attached. api attaches none. worker attaches the claim loop, run_loop, finalize, and post-run hook enqueue. maintenance attaches the four sweeps, each behind its advisory lock. All three are the same binary with a role flag, so the flag selects a branch in this phase and nowhere else.

Readiness is wired here too, and wired to the database and to Composition.settings — never to a provider client. Section 16: "Readiness should verify the database and critical configuration, but it should not call a model provider on every probe." Placing the probe in the composition root rather than in the API module is what makes that easy to keep true, because the probe is built from a Composition that has no provider client on it.

Where each stated constraint lands

Constraint Source Phase
Ports defined before adapters plan §7 authoring, not runtime
Explicit construction, no DI framework rule 14 the shape of all five
No global singleton DB sessions rule 13 3
Hardline rules frozen at load plan §15 4, first
Policy profiles compiled once, frozen policy spec 4
Reserved domains are a startup error tool spec 4
Trust labels forced at registration tool spec 4
Sampling and roots declined tool spec 4 (M6)
Determinism before ambient time eval spec 2
Authentication configured or fail plan §16 1
Production refuses the dev sandbox ADR-0008 1
Production cannot load an eval identity eval spec 1 and 4
Readiness must not call a provider plan §16 5
Prefix classes fit the 22,000 ceiling context spec 1 and per session
Role-conditional composition runtime spec 5
Migrations upgrade cleanly plan §25 not startup
A port with no contract module fails eval spec build gate

Three of the seventeen are listed as not startup constraints, and saying so is part of the specification rather than an omission from it.

Migrations are the important one. The composition root never runs migrations. It asserts that the database is at the revision the code expects and refuses to start otherwise. Migrating on boot means N processes racing to migrate the same database during a rolling deploy, and it means a process that failed to start has already changed the schema. Section 25 already treats migration as a step ("Run migrations") separate from starting anything. The mechanism this leaves open — where the expected revision comes from, and why it is a constant rather than a computed head — is specified in event-log-and-persistence.md under "The revision the code expects is a constant", along with the migration-authoring conventions the assertion is only as good as.

Provider pinning is per run, not per process: ADR-0007 fixes routing "once at run start", which is RunService's concern. And contract-module coverage — "a port with no contract module is a build failure" — is checked when the suite is collected, not when a process starts.

What the composition root must not do

Five prohibitions, each of which is a static check rather than a convention:

  1. It is imported only by entry points. bootstrap may be imported by api/main.py, cli/main.py, and the worker entry, and by nothing else. A service that imports bootstrap has found a way to construct its own dependencies, which is rule 14 defeated from the inside. This is one edge in the import-graph walk Milestone 0 already builds.
  2. It constructs no AsyncSession. Rule 13, checked at module scope.
  3. It imports nothing from agent_core.evals. The general rule — no production module may import the evals package — has no exception for the composition root, and the composition root is the module most tempted to make one.
  4. It calls no model provider. Constructing a client is phase 3; calling one is a run.
  5. It holds no module-scope state. build is a function. There is no module-level container, no get_container(), and no import-time side effect — which is what "do not add a dependency-injection framework" forbids in practice, whatever the framework is called.

The three entry points

api/main.py        role=api            FastAPI app over services
cli/main.py        role=api            twelve commands over services
runtime/worker.py  role=worker         claim loop; or role=maintenance

The CLI builds the same graph as the API and calls the same application services. Section 17 is unambiguous about why: "The CLI must call the same application services as the API. Do not implement a second runtime loop inside the CLI." A CLI that built its own graph would be one refactor away from having its own loop, so it does not build its own graph.

The worker entry takes the role flag and attaches either the claim loop or the sweeps. One binary, three roles, one build.

Every production unit, the execution service and the calling ingress included, starts through that binary, so each start imports whatever cli/main.py and bootstrap.py import. The composition root therefore imports the provider SDK adapters and the MCP SDK client factory where it constructs them, not at module scope: a role that never builds a model provider or an MCP client, such as the schedule, notification, or execution-service role, never loads those SDKs.

The API and the interactive worker are the two entries that pin Chat catalogs, so they start the MCP runtime's discovery warm-up (ADR-0131): the interactive worker after build, and the API once its socket accepts connections, so readiness never waits on an MCP server and no server startup delays the bind. It runs in the background, discovers each enabled server of the configured tenant one at a time with a patient handshake timeout, retries the ones that fail, and closes each. One-shot CLI commands and tests never start it.

Told to stop, the API gives in-flight requests API_SHUTDOWN_GRACE_SECONDS (5 s) and then cancels them. A run's event stream never ends by itself, and its client resumes from Last-Event-ID; without the grace, one open stream held the API until systemd killed it at its 30 s stop timeout. The rest of that timeout is left to the composition's teardown.

Milestone 1: the in-memory tier

Milestone 1 names "In-memory repositories" in one bullet and never returns to it. The bullet has to answer four questions before anyone can write the file: which ports, in which module, with which guarantees, and whether the result is production code or a test double.

Which ports, and where

Five, in a new adapters/persistence/memory.py:

AgentRepository            the one default agent from configuration
SessionRepository          create, get
RunRepository              create, get, transition
EventRepository            append, list_after
ToolInvocationRepository   record, list_for_run

RunQueue is deliberately absent. Milestone 1 uses the inline dispatcher, so nothing claims and nothing leases, and an in-memory RunQueue would be a simulation of the one mechanism — FOR UPDATE SKIP LOCKED with lease_epoch fencing — that exists precisely because it cannot be simulated. Checkpoints and approvals are absent for the same reason in reverse: nothing at Milestone 1 suspends, so there is nothing to store.

The module grows as ports arrive, but it never grows to cover a port whose whole point is a database guarantee.

They are adapters, not test doubles

They live under adapters/, they implement the same Protocols, and they are exercised by the same contract suites as the SQLAlchemy implementations. ADR-0001 defines replaceability as "a port with a contract suite attached to it", and an in-memory implementation that ran against a different suite would not be replaceable — it would be a second, unverified definition of what the port means.

Some contract assertions cannot hold in memory. Crash recovery, transaction rollback across repositories, and advisory-lock behaviour all require a real database. The suite is therefore parameterized over adapters, and each adapter declares the capability groups it satisfies against a checked-in table. A group that an adapter does not declare is reported as not-applicable, and a group it declares but fails is a failure. The declared set is itself asserted against the table, so "skipped" is never something an adapter can quietly award itself.

What is guaranteed in memory

The invariants that are observable inside one process are held exactly: per-session event sequence is monotonic with no gaps, appends are append-only with no update path, and transition rejects a transition the state machine does not allow. Each repository serializes its mutations behind one asyncio.Lock, which is sufficient because Milestone 1 is one process and the inline dispatcher runs one run at a time.

The invariants that are not observable in one process are not faked. There is no durability, no cross-repository transaction, and no recovery. A Milestone 1 process that exits loses everything, which is correct for a vertical slice and is why Milestone 2 exists.

The event store at Milestone 1

Milestone 1's acceptance criteria include "Every state transition is represented by an event", and Milestone 2's implement list includes "Append-only event storage". Read as a pair, they look like a contradiction: the criterion needs an event store one milestone before the store is built.

They are not in conflict once the two words are separated. Milestone 1 needs an event repository — something that assigns a sequence and records the thirteen-event trace the plan prints at Section 26. Milestone 2 builds append-only event storage — the durable table, the schema versioning, the upcasting read path. The EventRepository port is declared at Milestone 1 because the acceptance criterion requires it; the in-memory implementation satisfies it at Milestone 1; the PostgreSQL implementation replaces it at Milestone 2 and adds durability the port never claimed to provide.

The per-session monotonic sequence invariant applies to both. It is the one guarantee the trace depends on, and it is cheap in memory.

The run dispatcher

ports/dispatch.py and adapters/dispatch/inline.py and adapters/dispatch/postgres.py are all named in the tree. Section 7 lists "Run dispatcher" among the ports that get a bullet and no Protocol body. This is the body.

class RunDispatcher(Protocol):
    async def dispatch(self, run_id: UUID) -> None:
        """Guarantee that run_id will be executed exactly once.

        Called after the unit of work that created the run has
        committed. Returning does not mean the run has finished.
        """

One method, and the whole design is in the docstring's second paragraph.

Both adapters satisfy the same postcondition

The inline adapter runs the loop to completion and then returns. The PostgreSQL adapter inserts the queue row, notifies, and returns immediately. These look like different contracts and are not: both guarantee that after dispatch returns, the run will be executed exactly once. They differ in when that has already happened, which is latency, not semantics.

That framing decides the one thing callers get wrong. A caller must never assume the run has finished when dispatch returns. agent run at Milestone 1 will in fact find a completed run, because the inline adapter finished it; code written against that observation breaks the day the PostgreSQL adapter is swapped in. So RunService returns a run identifier and the CLI polls the run's status, at Milestone 1, when polling is redundant.

Dispatch happens after commit

If dispatch is called inside the transaction that created the run, a worker can claim a row that has not committed yet, and the failure is a RunNotFound in a process that has no idea why. The rule is therefore absolute: dispatch is called after the unit of work commits, never within it.

Milestone 1 cannot observe a violation of this rule — the inline adapter runs in the same task and would work either way — which is exactly why the rule needs a test rather than a convention. The inline adapter asserts that no unit of work is open when it is called. A Milestone 2 race becomes a Milestone 1 assertion failure, in the milestone where the code is being written rather than the one where it is being scaled.

The Milestone 1 context builder

context-engine.md specifies the finished builder and places its hard gates on Milestone 7. Its build sequence has seven steps and no milestone tags. Step 1, "deterministic assembly", is the Milestone 1 builder — the two regions, the fixed order, trust labels on every item, and prefix_sha256 recorded from the first commit. The spec is explicit about why that step cannot wait: the hash "is not retrofittable onto traffic that has already been served unstably."

Region assignment for the seven Section 11.1 inputs

Section 11.1 input Region Why
Platform policy A Fixed for the deployment
Agent instructions A Fixed for the session
Available tool definitions A Fixed once filtered
Relevant conversation items B Grows every turn
Current goal and constraints B Editable within a session
Current user message B Volatile by definition
Runtime metadata (date, scope) B The canonical volatile item

Region A is exactly the three classes Section 10.1 names as the byte-stable prefix. The memory snapshot is a fourth Region A class in the finished design and is empty at Milestone 1, because there is no memory yet — its absence changes no bytes, which is the property that lets it be added later without breaking a cached prefix.

The default agent's instructions also establish a least-powerful-tool routing rule. Routine arithmetic, date/time questions, and public facts prefer the available read-only capabilities (math.calculate, system.current_time, and web.search) and do not fall back to sandbox.run_command. If no read-only capability can answer, the agent explains the limitation or asks before proposing arbitrary code execution. This is model guidance, not authorization: the deterministic policy and approval pipeline remains authoritative for every tool call the model does propose.

The invariant, and its test

Region A is built once per session, serialized to bytes with a fixed separator, and hashed. prefix_sha256 is recorded on every model_calls row the session produces. The test is two builds:

  1. Build a request for a session. Record prefix_sha256.
  2. Advance the injected Clock, add a conversation item, build again.
  3. Assert the two prefix_sha256 values are identical.
  4. Assert the two serialized requests are not, and that every differing byte falls in Region B.

Step 4 is the half that matters. Without it the test passes for a builder that puts everything in the prefix and never changes anything, which is prompt-stable and useless. This is the Milestone 1 form of the cache-boundary enforcement test the context spec places at Milestone 7, and it needs the Clock port from the determinism harness, which is why both land in the same milestone.

Tool filtering

Section 11.1 names four filters — agent configuration, principal authorization, policy profile, runtime environment — and all four exist as stages from the first commit. Two of them do nothing at Milestone 1: there are no policy profiles until Milestone 4 and one runtime environment until Milestone 8. They are written as identity stages rather than omitted, because the plan's own rule for this class of decision is that builder shapes later code hardens around are cheap now and expensive to retrofit.

The output is capped at 30 tools before it reaches the prefix. Milestone 1 registers two.

The CLI contract

Section 17 lists twelve commands. Nine of them have no behaviour stated anywhere in the corpus, agent chat has seven numbered steps, and agent run has a required demonstration. The section closes with the one rule that matters: "The CLI must call the same application services as the API. Do not implement a second runtime loop inside the CLI."

This is the twelve commands with arguments, output, and exit codes. Nothing here changes a command's spelling.

The twelve

Command Arguments Stdout First milestone
agent session create — the session id, one line 1
agent chat — the transcript 3
agent run prompt the final message 1
agent run get run id the run record 1
agent run events run id the event trace 1
agent run cancel run id the resulting status 5
agent approval list — pending approvals 4
agent approval approve approval id the resolution 4
agent approval deny approval id the resolution 4
agent eval run suite, optional the suite result 1
agent worker — nothing; runs until signalled 2
agent api — nothing; runs until signalled 1

agent session create prints the session identifier and nothing else, so SESSION=$(agent session create) is the intended usage rather than a lucky accident. Every other read command follows the same discipline: the result goes to stdout, and progress goes to stderr. Milestone 1's required demonstration prints six lines of flow — run created, model requests math.calculate, and so on — and those six lines are stderr. The final assistant message is stdout. agent run "..." > answer.txt then does the obvious thing.

Reserved words after agent run

agent run "Calculate 12 times 9" and agent run get <run-id> are the same command with different first arguments, which is a genuine ambiguity in the spelling the plan chose. It is resolved by reserving words: get, events, and cancel are subcommands of run, and any other first argument is a prompt.

The residual collision — a prompt that is exactly the word get — is accepted rather than designed away, because the alternative is renaming a command the plan fixed. agent run -- get passes the literal string.

The set is open to a subject spec that needs one, on the same terms evaluation-harness.md already uses when it adds five subcommands under agent eval without changing the twelve: a subcommand under an existing command is not a new command. event-log-and-persistence.md adds export on that basis, making the reserved set four words. ADR-0131 adds latency, a read-only, aggregates-only latency report over recent Chat turns, making it five. A spec adding one pays exactly two costs — a line here and one more prompt that needs -- — and both are cheaper than a thirteenth top-level noun, because the twelve is a number Section 17 states and this document's own heading repeats.

Milestone 9's human-memory acceptance contract is the exception that later proved necessary: memory-formation-and-consolidation.md and ADR-0045 add the agent memory noun. Its management commands are list, get, edit, delete, and ADR-0117's review; its diagnostic commands are formations, which may filter by source session, diagnose, which joins one session's formation flags, watermarks, provider attempts, audits, and beliefs, and trace, which reads one principal-scoped retrieval trace. replay --session --confirm is an operator repair command that reprocesses original source events through the ordinary formation service. These remain CLI calls over the ordinary composition and repositories. They insert no belief rows directly and implement no second runtime loop; the HTTP review and deletion routes are ADR-0117's, not the CLI's.

Later subject documents add nouns on the same terms, each owned where its subject is designed: agent persona in persona-surface.md, agent surface in inbound-surfaces.md, agent people in people-and-relationships.md, agent email exclude-bulk in email-experience.md (ADR-0116), and the role entry points agent execution-service (ADR-0067) and agent call-worker and agent call-ingress (bland-calling.md). agent session export-consent is ADR-0039's, and agent eval carries the subcommands evaluation-harness.md and the memory and People evaluation documents add.

Options

The plan names no flags, and this document adds five. Four make their commands usable; the fifth prevents a fixed local wait from classifying a slower successful run as unreachable:

--json           machine-readable result on stdout, on read commands
--session <id>   reuse a session instead of creating one
--role <role>    worker | interactive | async | maintenance | schedule
                 | notify | surface, on agent worker
--follow         stream events as they arrive, on agent run events
--wait-timeout <seconds>
                 positive terminal-state wait, on agent run; default 300

--json is what makes the CLI scriptable and is what agent eval run consumes when a harness invokes it. --role is how the maintenance role gets an entry point at all — the plan names agent worker and the runtime spec names three roles, and one of the three would otherwise be unreachable. scheduling.md later split the interactive and async workers and added the schedule role, notifications-and-devices.md the notify role, and inbound-surfaces.md the surface role; plain worker, the default, is the legacy spelling of the interactive worker. --wait-timeout controls only how long agent run waits for a terminal or suspended state and its persisted terminal event. Expiration keeps exit code 5 and prints the durable run identifier; it neither cancels nor fails the run. The API and native clients remain asynchronous and do not inherit this local CLI setting.

The later agent memory extension is JSON-only and adds its own bounded inspection options: --include-inactive, --session <id>, and --limit <1..200>. The first applies only to belief listing; the source-session and limit filters apply to beliefs and formation audits. Diagnosis and replay require one --session; replay additionally requires --confirm. A successful agent memory delete <id> writes {"id":"<id>"} so deletion remains inside the same JSON-only contract.

Exit codes

0  the command did what it says
1  the run reached a terminal failure, or an approval was denied
2  usage error - bad arguments, unknown subcommand
3  the run suspended on something the CLI could not supply
4  configuration refused at startup - phase 1 failed
5  the platform is unreachable, or the local run wait expired

Codes 4 and 5 are separated because the distinction is the only one a calling script actually needs: 4 will fail identically on retry, and 5 may not. Code 3 covers the case where agent run produces a run that suspends waiting for an approval and the invocation was not interactive; the run identifier is printed so the caller can resolve it with agent approval approve and the run continues where it stopped.

agent chat and the seven steps

The seven numbered steps in Section 17 are a specification of a loop, and the loop calls application services for every one of them:

  1. Create or resume a session — SessionService.
  2. Submit a user message — RunService, which returns a run id.
  3. Stream events — the event broadcaster, filtered to that run.
  4. Display tool activity concisely — one line per invocation, from tool.call.started and tool.call.completed, never from model output.
  5. Prompt for approvals — on approval.requested, read from the terminal and call ApprovalService.
  6. Render the final assistant message — from the terminal event, not from accumulated stream deltas.
  7. Display artifact paths or ids — ArtifactService.

Step 4 and step 6 both carry a rule worth stating because both are easy to get wrong in the direction that produces a second runtime loop. Tool activity is rendered from events, so the CLI never inspects a model response to decide what a tool did. The final message is read from the run's terminal event rather than assembled from deltas, so a dropped or reordered stream chunk cannot make the CLI's rendering disagree with what the platform recorded. Those two rules are the whole of "do not implement a second runtime loop inside the CLI", made concrete.

agent chat arrives at Milestone 3, when there is a real provider to stream from. Milestone 1 has agent run.

The Milestone 0 checks

Milestone 0's implement list has eleven bullets and its cross-reference paragraph adds six deliverables that are not in the list: the gate registry, the docs check, the import-boundary walk, the transaction-hygiene check, the secret scanner, and contract-module coverage. The paragraph also states why they land in a milestone with no runtime: "Both run against an almost-empty repository and stay correct as it fills; added later, they are added against existing violations, which is the situation in which they get relaxed rather than obeyed."

Five of the six are specified elsewhere. The secret scanner is named nine times across the corpus and specified nowhere, and the transaction-hygiene check is placed in two milestones at once. Both are settled here.

The static checks this document adds

The import-boundary walk already exists as a mechanism. These are the edges and module-scope assertions the composition root contributes to it:

  1. agent_core.bootstrap is imported by api/main.py, cli/main.py, and runtime/worker.py, and by nothing else. A service that can import bootstrap can construct its own dependencies.
  2. No module outside bootstrap.py instantiates a class imported from agent_core.adapters. This is ADR-0001's "assertion that construction happens in one module" as an AST rule rather than a hope.
  3. No module outside adapters/determinism.py calls datetime.now, time.time, uuid.uuid4, or anything in random. This is the checkable negative that justifies Clock and IdFactory sharing a port module.
  4. No AsyncSession is constructed at module scope, anywhere. Dependency rule 13.

All four are true of an empty repository and stay true as it fills, which is the property the milestone paragraph asks for.

The secret scanner

Gate id gate.structure.no_committed_secrets, registered at Milestone 0. The engineering plan's Milestone 0 acceptance criteria declare it, in the same breath as the import-boundary walk; this section specifies it. The area is structure rather than security because the identifier grammar in milestone-map.md defines no security area, and a repository-wide scan owned by no subject spec is what structure is for.

It scans committed text — everything under src/, tests/, evals/, migrations/, docs/, plus .env.example and any committed fixture — and fails the build on a match.

Five rule families, named so a failure report can say which one fired:

provider_key     sk-*, sk-ant-*, and the profile-declared prefixes
private_key      PEM BEGIN blocks of any type
bearer_literal   a literal Authorization: Bearer with a value
dsn_password     a connection string with an inline password
assigned_secret  a name matching secret|token|password|api_?key
                 assigned a literal longer than twelve characters

Three properties matter more than the patterns:

  1. The report never prints the match. It prints the path, the line number, and the rule name. A scanner that echoes the secret into a CI log has moved the secret somewhere worse.
  2. The allowlist requires a reason. An entry is a path:line:rule triple plus prose; an entry without prose is itself a failure. The cost of suppressing a finding should be having to write down why.
  3. .env.example is scanned, not exempted. It is the file most likely to acquire a real value by accident, and the definition of done already says "No secrets appear in fixtures, logs, or committed files."

The scanner runs in make check and therefore in CI, and its findings are build failures rather than warnings.

Transaction hygiene at Milestone 0

The plan places the transaction-hygiene check in Milestone 0. runtime-loop.md tags the corresponding hard gate Milestone 2, and the acceptance criterion it enforces — "Database transactions are never held across provider or tool I/O" — is a Milestone 2 criterion. Milestone 0 has no database code to walk.

Both are right, because they are talking about two different things. The check is a Milestone 0 deliverable; the gate is a Milestone 2 criterion. At Milestone 0 the AST walk is written, registered, and run, and it finds zero violations because there is nothing to violate. At Milestone 2 the same walk becomes an acceptance criterion for the milestone that first makes violating it possible.

This is the whole point of the milestone paragraph's argument. A check written against an empty repository is written against the rule; the same check written at Milestone 2 is written against whatever the code already does.

Contract-module coverage

"A port with no contract module is a build failure." At Milestone 0 there are few ports and no adapters, and the check still runs: it enumerates the Protocols under agent_core.ports and asserts a corresponding module under tests/contract/. It is one of the two reasons tests/contract/ must exist at Milestone 0 rather than at the first adapter.

Conflicts this document resolves

Five, all resolved additively — no requirement is rewritten, and in each case the plan's text stands with an annotation rather than a replacement.

  1. runtime/engine.py versus loop.py and executor.py. The Section 4 tree names one module; runtime-loop.md splits it in two and restricts RunRepository.transition to one of them. The split wins, engine.py is retired, and supervisor.py joins them.
  2. .env.example versus 205 file-layer knobs. The definition of done stands: every newly accepted environment key appears in .env.example. File-layer paths are not environment keys and remain enumerated and documented by their owning committed defaults; moving a key into a default file does not satisfy this rule if the process still accepts it from the environment.
  3. The Milestone 1 event criterion versus Milestone 2 event storage. An event repository is Milestone 1; append-only event storage is Milestone 2. The port is declared once and implemented twice.
  4. Transaction hygiene in Milestone 0 versus Milestone 2. The check is a Milestone 0 deliverable; the gate is a Milestone 2 acceptance criterion.
  5. agent run <prompt> versus agent run get <id>. Five reserved subcommand words, and -- for the literal.

Decisions

  1. The composition root is the only module that knows both a port and its adapter. Every other module knows one or the other. That is dependency rule 14 stated as a property, and it is what makes in-memory-for-Postgres and fake-for-OpenAI configuration changes rather than code changes.
  2. A value is an environment variable if and only if it differs between two deployments of the same revision and cannot be committed. That sorts all 200 declared knobs into files and leaves ten fields in Settings.
  3. The environment never overrides a file; it is interpolated into one at named non-policy points. Policy-semantic documents reject interpolation, so a deployment cannot change an effective policy rule without changing the policy_version hash the rule is recorded under.
  4. Configuration YAML lives beside the package that owns it, following the one precedent the plan set with policy/hardline.yaml, and an operator overlay directory merges over it file-by-file so the merged document is still hashable.
  5. hardline.yaml cannot be overlaid. Attempting it is a startup error. An overlay is configuration, and the hardline set is defined as the thing configuration cannot disable.
  6. Startup has five phases, ordered by what each may touch: refuse, determinism, resources, freeze, wire. Every startup constraint in the corpus lands in one of them, and three constraints that are commonly mistaken for startup constraints are named as not being any of them.
  7. The composition root never runs migrations. It asserts the schema revision and refuses to start on a mismatch. Migrating on boot means N processes racing during a rolling deploy and a failed start that has already changed the schema.
  8. build returns application services only. No adapter, repository, or session factory is reachable from a Composition, which is how the runtime spec's single-caller reservation on RunRepository.transition survives a second entry point.
  9. The in-memory tier is five adapters, not test doubles, run against the same contract suites, with each adapter declaring the capability groups it satisfies against a checked-in table so a skip is a reviewed fact rather than a runtime accident.
  10. There is no in-memory RunQueue. Its whole content is FOR UPDATE SKIP LOCKED and lease fencing, which is the one thing a simulation cannot tell the truth about.
  11. RunDispatcher has one method and both adapters satisfy the same postcondition: after dispatch returns, the run will be executed exactly once. Inline and PostgreSQL differ in latency, not semantics, so a caller may never treat a returned dispatch as a finished run.
  12. The inline dispatcher asserts that no unit of work is open when it is called. Dispatching inside the creating transaction is a Milestone 2 race that Milestone 1 cannot otherwise observe; the assertion moves the failure into the milestone where the code is written.
  13. The Milestone 1 builder is build-sequence step 1, and its prompt-stability test asserts both halves — the prefix hash is stable and the request bytes differ, in Region B only. The first half alone passes for a builder that never changes anything.
  14. All four tool filters exist from the first commit, two of them as identity stages until their milestones arrive, because the plan's own rule for builder shapes is that they are cheap now and expensive to retrofit.
  15. CLI results go to stdout and progress goes to stderr, so the Milestone 1 demonstration's six flow lines and its final answer are separable without a flag.
  16. get, events, cancel, export, and latency are reserved words after agent run. The residual collision with a prompt that is exactly one of them is accepted, with -- as the escape, rather than renaming a command the plan fixed. The set is open to a subject spec that needs a subcommand, which is not the same thing as a new command.
  17. The secret scanner never prints what it matched, requires prose on every allowlist entry, and scans .env.example rather than exempting it.
  18. The transaction-hygiene check is a Milestone 0 deliverable and a Milestone 2 gate. Writing the check against an empty repository writes it against the rule; writing it at Milestone 2 writes it against whatever the code already does.

Open questions for review

  1. DATABASE_URL is invented here. The plan names PostgreSQL as the source of truth, gives its schema, and requires migrations, but names no variable for reaching it. The name chosen is the SQLAlchemy convention. If the deployment target supplies a database address under a different name, this is a one-line change in config.py.
  2. Credentials are a flat mapping keyed by provider profile. Section 10.7 requires credential pools with round-robin selection, failover, and cooldowns at Milestone 3. A pool needs more than one value per profile, so Mapping[str, SecretStr] becomes Mapping[str, Sequence[SecretStr]] then. Whether that arrives as a list in one variable or as indexed variables is a Milestone 3 decision, not one this document should make.
  3. The overlay merges by top-level key, not deeply. Replacing an entire model_policies block to change one model is blunt, and deep merging makes the effective document harder to predict from the files. The blunt version is chosen for reviewability; if operators find it painful in practice, per-key merging is a compatible change.
  4. Five CLI options are added where the plan names none. --json, --session, --role, --follow, and --wait-timeout. Each is defended above, but the plan's silence on flags may have been deliberate minimalism rather than an omission. ADR-0055 records the fifth option and supersedes ADR-0024's original count without changing the command roster.
  5. The capability-group table for contract suites is described, not enumerated. Which groups exist — durability, isolation, concurrency, recovery — should be fixed when the first contract suite is written, against a real port rather than in the abstract.

Legacy Email semantic activation evidence

With People explicitly disabled, the legacy Milestone 26 policy uses Settings.email_semantic_evidence, read from AGENT_EMAIL_SEMANTIC_EVIDENCE. It is an optional path to a reviewed semantic email evaluation artifact. The composition selects evidence for the resolved provider/model and exact release identity using the mechanism in email-experience.md. Missing evidence leaves semantic formation disabled; stale or mismatched evidence cannot enable it. This is independent of the Email UX flag and the existing Chat memory policy selection.

Milestone 27 calling composition

The Bland calling design adds default-off AGENT_CALL_ENABLED, AGENT_CALL_INGRESS_ENABLED and AGENT_CALL_NOTIFICATIONS_ENABLED, the public BLAND_CONFIGURATION_FILE, and role-confined BLAND_API_KEY_FILE and BLAND_WEBHOOK_SECRET_FILE. Enabled configuration fails closed when incomplete; inline API keys are rejected. The fixed sibling bland_mcp package runs in read/call modes through the existing MCP process boundary and cannot import core. agent call-worker and agent call-ingress use a dedicated composition without model credentials, an owner API bearer, execution or unrelated MCP tools. The worker reconciles results and erases expired content; ingress queues signed IDs with a receipt-only database role and separate operating-system user. Both require production PostgreSQL, a current migration and an explicit owner binding. See setup for the complete configuration and role inventory.

Scale VP bridge composition

ADR-0153 adds default-off AGENT_SVP_ENABLED and SVP_CREDENTIAL_FILE, the absolute path of the owner-only grant file. Enabled without that path, or with a path that is relative, linked, missing or not 0600, startup fails closed; the path without the flag is refused. Core never reads the file: the credential resolver carries its path, and the fixed sibling svp_mcp package, which cannot import core, reads and rewrites it. Composition adds the svp_read row and its mcp.svp_read.use scope to the owner's principal. The mechanism is in tool-system.md.

Milestone 28 People composition

AGENT_PEOPLE_ENABLED defaults to true and controls People capture, public management routes, read tools, and automatic context integration. Explicitly setting it to zero stops new work while owner/source/session erasure stays wired. Under ADR-0101, formation@11 is selected without evaluation artifacts when no legacy formation policy is pinned. email-semantic@2 is used whenever People is enabled; existing Email credentials, source permissions and budgets apply. The evidence settings remain available for legacy policies and quality reporting; missing or stale People evaluation artifacts cannot disable functionality.

The composition root connects People services to governed corrections, recall, and import formation through ports. It injects source admission, implementation identity, and canonical message wrapping into the import workers. The in-memory People adapter receives its memory store so directory relationship filtering can check belief privacy before pagination; PostgreSQL applies the equivalent scoped join. The detailed contract is people-and-relationships.md.

Milestone 29 thread folder composition

AGENT_THREAD_FOLDERS_API_ENABLED defaults to off and gates both the folder router and the proposal sweep. The composition root loads the checked-in folders/profiles.yaml document, constructs the folder service over the unit-of-work factory, and — only when the flag is set and proposals.enabled is true — builds the model-assisted grouper over the model router and providers with the lexical grouper as its fallback, and registers the proposal pass as a maintenance sweep on its own timer. Its seven knobs join the executable inventory when the implementation lands. The detailed contract is thread-folders.md.

ADR-0110 adds one selector and one optional wrap. JUDGMENT_PROVIDER accepts disabled and typesafe and defaults to disabled; only typesafe makes the composition root construct the TypeSafe judgment provider over the credential resolver and the injected clock, and close it on shutdown. TYPESAFE_API_KEY enters the credential broker as the reference typesafe. A selector with no credential logs one warning that names variables only and never refuses startup; the provider then fails each call without dialing. When a provider is composed and proposals.judgment_matching_enabled is true, the root wraps the grouper above in the judgment matcher; otherwise the grouper is composed exactly as before, and the knob with no provider logs one folder_judgment_matching_unavailable warning. The two judgment knobs are in the executable inventory and the knob table. The detailed contracts are typed-judgment.md and thread-folders.md.

Milestone 30 advisory approval composition

The composition root always builds the deterministic policy engine. Only when the loaded profile has advisory.enabled true, which enforces, or AGENT_POLICY_ADVISORY_OBSERVE_ENABLED is set, which only observes, and a judgment provider is composed, does it wrap that engine in the advised engine over a judgment-backed advisor; with the layer on and no provider it logs one policy_advisory_unavailable warning and uses the deterministic engine, and never refuses startup. The tool pipeline receives the advised engine as its policy and the deterministic engine as its recovery policy, and the standing authorizer keeps the deterministic engine. The observe flag is an environment value because it changes no effective rule; enforcing stays in the hashed profile. No knob is added: the shipped profile is unchanged. The detailed contract is policy-and-approvals.md.

Milestone 31 email unsubscribe composition

AGENT_EMAIL_UNSUBSCRIBE_ENABLED defaults to off and requires both AGENT_EMAIL_ENABLED and AGENT_EMAIL_MODE_ENABLED; set without them it is a configuration error at composition. When set, the composition root mounts the subscription routes, registers the email.subscriptions and email.unsubscribe builtin tools, lets the refresh task build the census, and constructs the process-local public-HTTPS unsubscribe transport, handing its address to email.unsubscribe and to nothing else. The operator's egress policy is not consulted for that transport and cannot select it. Unset, none of these exists. The email.unsubscribe_grace_days limit, default 10 and bounded from 2 through 60, is the one versioned knob this milestone adds to the executable inventory; the flag is an environment variable like the other feature flags. The proxy and the transport are owned by the composition and closed with it. The detailed contract is email-unsubscribe.md.

ADR-0120 attachment composition

AGENT_ATTACHMENT_UPLOADS_ENABLED defaults to off. When set, the API mounts the upload route and the middleware admits its 32 MiB body. Whatever the flag, the composition root builds one attachment resolver over the unit-of-work factory, the filesystem artifact store, the principal, and the clock, and passes it to every provider adapter it constructs, so a conversation that already holds attachments renders them after the flag is turned off; a provider passed in as an override receives none and renders markers. The maintenance worker registers the owner-sent knowledge ingestion as one more sweep, over the knowledge service with the PDF extractor adapter composed in front of the plain-text one. No versioned knob is added; the limits are constants in agent_core.model.attachments. The detailed contract is ADR-0120.

ADR-0129 browser task-grant composition

BROWSER_TASK_GRANTS_ENABLED defaults to off and requires BROWSER_PROVIDER=hosted. BROWSER_TASK_GRANT_SCOPES is empty by default, requires the flag, and is parsed at startup into exact scopes of one public-HTTPS origin and one path segment; a malformed, duplicate, or sensitive scope is a configuration error. When the flag is set, the composition root places a task-grant authorizer after the pinned standing-grant authorizer in one composite, hands the resolution service a task-grant resolver, mounts the task-grant routes, and registers the task-grant expiry sweep on the maintenance worker. The browser.act approval presenter is composed either way and makes no offer while the flag is off. The flag remains a deployment control. ADR-0141 makes scopes owner-managed server state: startup inserts the configured seed only when the owner has no persisted policy; later starts never overwrite edits, including an empty list. API and worker components read the repository for offers, resolutions and use consumption, without a process cache. Durations and caps remain constants in agent_core.domain.browser_task_grants. The detailed contract is browser-automation.md and ADR-0129.

General memory source-index composition

ADR-0187 connects the bounded source indexer to maintenance through AGENT_MEMORY_SEMANTIC_INDEX_ENABLED (default 0), AGENT_MEMORY_SEMANTIC_INDEX_MANIFEST and AGENT_MEMORY_SEMANTIC_INDEX_EVIDENCE. The paths identify an independently reviewed release identity and its measured passing bundle. They contain no credentials. The existing release identity must be configured, and production requires PostgreSQL. The manifest path may instead contain ADR-0199's recall-deployment-binding@1 receipt, preserving the measured identity while separately naming the deployed commit. Composition verifies its complete input commitment and original evidence before provider construction. An ordinary identity file retains the exact-commit rule; this adds no deployment setting or activation default.

The checked-in memory/semantic-index.yaml catalog ships with schema_version: 1 and an empty configurations list. Its operator overlay must contain exactly one entry with the complete EmbeddingConfig under embedding. A remote tuple also requires credential_ref. The credential reference names the existing worker credential broker entry, such as openai; the provider's existing secret-loading path supplies its value. Model, revision, dimensions, price/revision, egress revision, residency and request/day limits are explicit reviewed file values. Interpolation is forbidden. The empty optional catalog is metadata, not a new populated default in the knob inventory.

Composition compares locally derived policy/profile/agent/owner, context, model catalog and embedding identities with the independent manifest, then invokes the complete release scorer before accessing credentials. Both files remain revocable. Disabling the flag skips evidence and credential access. Configuration changes require recomposition and matching evidence; source originals remain authoritative. ADR-0188 also admits an explicitly local tuple: local contains model/tokenizer SHA-256, dimensions, token capacity and CPU thread limit; model_directory is an absolute directory containing verified regular model.onnx and tokenizer.json files. It has no credential reference, uses local residency, local-embedding@1 execution and zero provider price. No model download occurs at runtime. Model installation and matching release evidence must precede activation. Enabling the existing retrieval.semantic_enabled profile also requires an explicit reviewed reader_sha256; a run's actual selected reader must match it. Composition then supplies run-bound local lookup and explicit preparation before assembly and pending tool batches, with sparse fallback. Both evidence files remain mandatory. Remote tuples still receive no query egress authority. The checked-in catalog and retrieval flag remain disabled. The separate retrieval.source_recovery_enabled flag also defaults to false. Enabling it requires local semantic query authority and the same release-bound reviewed reader. It supplies bounded private, restricted, current-project source quotes under ADR-0189. An absent runtime binding or withdrawn evidence leaves ordinary fact recall available.

ADR-0209 browser research composition

BROWSER_RESEARCH_ENABLED defaults to 1 and accepts only 0 or 1. With a browser provider and the default bounded-scroll policy rule (allow scroll, require approval otherwise), composition inserts the research authorizer after specific follow consent and before standing/task grants. It requires a trusted owner or scheduler seed, an owner-selected READY profile and fresh observation, and supplies a live dispatch constraint. Setting 0 omits it. Custom unconditional approval and deny rules do not compose this permission. No versioned policy rule or memory evaluation tuple changes. The detailed contract is browser-automation.md.

ADR-0213 owner-requested calls

AGENT_CALL_OWNER_REQUESTS_ENABLED defaults to 1 and accepts only 0 or 1. With AGENT_CALL_ENABLED=1, composition appends the owner-call authorizer after the browser authorizers; setting 0 omits it and every call asks for approval. schedule.create receives whether calling is composed. The scheduler role derives mcp.bland_call.use and mcp.bland_read.use from AGENT_CALL_ENABLED as it derives Gmail scopes, reading BLAND_CONFIGURATION_FILE but never a Bland credential. The contract is bland-calling.md.