Decisions taken without review¶
Andy is away from 2026-07-24 for roughly 72 hours, with the instruction to continue the plan to the point where coding can begin, to make an intelligent decision at each obstacle rather than stopping, and to record every one of those decisions for review.
This file is that record. Each entry states what was decided, why, what the alternative was, and what it costs to reverse. Nothing here is settled; every item is open for a different answer.
The reversal cost is the only column that should drive urgency. Items marked cheap are prose changes. Items marked moderate touch a migration or a schema that has not been written yet, so they are cheap now and expensive after Milestone 2 ships. Items marked expensive would require rewriting a committed spec and its dependents.
Milestone 4 implementation decisions¶
ADR-0040 records the policy, approval, and workspace choices made during the autonomous implementation. The highest-value review points are:
- Narrow process-wide operational event stream (moderate): the ruleset is
durably recorded in
policy_profiles, andpolicy.profile.loadedis emitted to a separate append-onlyprocess_eventsstream because a hidden session would misstate ownership. Review whether later operational events should reuse this deliberately small stream or receive a broader design. - Approval HTTP binding waits for Milestone 5 (cheap): Milestone 4 exposes the complete transport-neutral application service and CLI. The HTTP route will bind that service when the plan's server, authentication, request IDs, and error envelope land together.
- Root listing uses the empty-path exception (cheap): containment rejects
empty path components, but
workspace.list_filesinterprets the schema's default empty string as the workspace root. - Restart lowers unproven file provenance (moderate): a local handle labels only paths written through that live handle as internal. Files recovered in a later process are external-untrusted until durable provenance is designed.
- Effect watermarks favor uncertainty over replay (cheap): every declared side effect is marked sent before tool code begins, even if a particular execution could finish without an external change.
- Mixed batches remain sequential (cheap): concurrency is admitted only when every call in the batch is an authorized, parallel-safe read.
- DNS protection is split across pure policy and future egress (moderate): literal protected addresses are denied by the frozen hardline rules. DNS is not resolved during policy evaluation; no hostname is allowlisted in the M4 tier, and any later network adapter must enforce protected ranges against the address actually connected.
- Legacy runs receive the pre-scope platform authority stamp (moderate): the
M4 migration backfills existing runs with the 15 scopes that were implicit
before scope enforcement. Leaving the migration default
[]would make every pre-M4 run unable to resume.
The complete rationale, consequences, and rejected alternatives are in ADR-0040.
Milestone 3 implementation decisions¶
ADR-0039 records the provider and trajectory-export choices made while the owner was away. The highest-value review points are:
- Two-field settings extension (moderate): Milestone 0 fixes an eight-field environment object, while Milestone 3 requires operator-controlled tenant export enablement and defines no tenant settings store. Export stays off by default through two additional composition fields until that store exists.
- Narrow trajectory byte store before the general artifact milestone (moderate): export needs expiring content-addressed bytes now. The local adapter implements only that port and must be replaced or adapted when Milestone 6 adds streaming general artifacts.
- One enabled profile per adapter (moderate): the normative resolved and pinned types identify the adapter, while the registry version identifies the profile. Supporting simultaneous endpoints behind one adapter needs a new selection key rather than an implicit map collision.
- Remote credentials fail at selection (cheap): startup installs an unavailable provider when a remote key is absent, allowing fake/local work while ensuring a selected remote policy fails closed.
- Single-tier prices restrict advertised windows (cheap): the first profile schema cannot express a long-context surcharge, so a profile declares only the context range its immutable price covers.
- Tenant redaction regexes use a safe subset (cheap): length, quantified groups, wildcard repetition, lookaround, backreferences, and conditionals are refused at construction to keep arbitrary exported text from causing catastrophic backtracking.
- Provider metadata has two readers (moderate): persistence flattening and bounded span attributes are the only readers. Runtime continuation travels in checkpoint-only provider-opaque items rather than creating a metadata dependency.
The complete rationale, local-review resolutions, consequences, and rejected alternatives are in ADR-0039.
Milestone 2 implementation decisions¶
ADR-0038 records the implementation decisions made while the owner was away. The highest-value review points are:
- Usage schema now, provider behavior later (moderate): the normative Milestone 2 schema owns model-call and price tables, while the model-gateway build sequence places real provider accounting in Milestone 3. The tables and fake-attempt write path exist now; no real provider behavior was pulled forward.
- Fixed PostgreSQL agent identity (cheap): independent CLI and worker compositions use one fixed UUID for the immutable built-in agent version. The memory tier keeps injected identifiers.
- Text tenant and principal keys (expensive after deployment): SQL follows the current string domain and evaluation namespaces rather than one UUID-typed pseudocode table.
- Additive read-model schema (moderate): session-history items, trajectory projection state, the pinned run seed sequence, final-message read model, and exact tool result are persisted because the active gates require them and the base schema is explicitly a minimum.
- Event-range checkpoint references (moderate): portable conversation items are projected from events through a pinned sequence rather than copied into checkpoint JSON. This is what makes checkpoint deletion a tested recovery path.
- Polling is the durable queue signal (cheap): workers poll the committed queue row at the configured interval. Notifications may reduce latency later, but correctness does not depend on receiving one.
- One composed unit-of-work port (moderate): application and runtime code select transaction boundaries through a port that groups repository ports; SQLAlchemy and row types remain confined to the persistence adapter.
- One injected checkpoint seeder (cheap): creation and total-loss recovery share the same event-prefix reconstruction callable, so the two required call sites cannot drift.
- Projection failures remain hard errors (moderate): a known event whose current-schema payload is malformed blocks projection progress. Silently skipping or quarantining it was rejected because the normative projection and upcaster contracts require a surfaced error; an operator repair path can be added only through an explicit later design.
The complete rationale, consequences, and rejected alternatives are in ADR-0038.
Process and environment¶
The plan is committed but not pushed¶
Decided: work happens on a branch, docs/plan-completion, cut from main
at dba12a5. Commits are made at each module boundary with the identity
Claude <noreply@anthropic.com>, passed per-command so the global git config is
untouched.
Why: the push cannot be performed from here. The bridge to the local machine
has no network access — SSH to github.com:22 is refused at the proxy — and the
cloud container that does have network has no write credential for the
repository. Committing locally preserves the work in git with real history
rather than leaving it as uncommitted working-tree changes.
Action required on return: review the branch, then run one git push -u
origin docs/plan-completion. Everything else is done.
Reversal cost: none. The branch can be rebased, squashed, or discarded.
Module order was chosen by accumulated dependency debt¶
Decided: the specs are written in the order event log and persistence, policy and approvals, model gateway and adapters, tool system and MCP, evaluation harness, runtime loop — not in milestone order.
Why: each of those is depended upon by specs already written. The context
engine (ADR-0020) assumes a policy_version that nothing yet defines; the
memory specs assume projections that rebuild. Writing the depended-upon layers
first means the existing specs get validated against them rather than drifting.
Note: the plan's own rule that evaluations are built before advanced features argues for pulling the evaluation harness earlier than fifth. It was kept at fifth because the harness needs the four specs above it to know what it is gating. This is worth a second opinion.
Reversal cost: cheap, and mostly moot once all six exist.
Event log and persistence (ADR-0003, ADR-0004)¶
payload_schema_version is added to the events table¶
Decided: the column is added in the first migration, SMALLINT NOT NULL,
even though nothing reads it until the first schema change.
Why: Section 6.8 and the Milestone 2 acceptance list both require payload versioning; Section 15's table omits the column. It is one column now and an unmigratable retrofit once a production log exists, because there is no correct value to backfill for events written before versioning.
Alternative: add it when the first event shape changes.
Reversal cost: moderate — trivial before Milestone 2, effectively impossible after.
Serialized allocation, not one appender, is what projection correctness rests on¶
Decided: projection correctness rests on every event writer allocating its
sequence with the atomic UPDATE sessions inside the transaction that inserts
the event. That statement holds the session row lock until COMMIT, so appends
to one session commit in allocation order. Section 27.5's "one active run per
session" default is kept as written but is no longer claimed as the mechanism.
Why the earlier record was wrong: the first version of this entry, and
ADR-0003 with it, attributed the guarantee to the one-active-run default and to
the partial unique index enforcing it. That index constrains the runs table to
one non-terminal run per session; it says nothing about who appends events. A
session already has two appenders — the submit handler appends the user message
from its own transaction while a worker appends run events — so the guarantee had
to come from elsewhere, and it does: PostgreSQL holds a row lock until
transaction end, not until statement end, which the specification had separately
stated incorrectly.
The question: unchanged in substance, but re-pointed. Should the allocation discipline be promoted from a documented rule to an enforced one — a single repository method no writer may bypass, with a test that fails when an append allocates outside its own transaction?
Interim position: state the discipline, name the two appenders that exist,
and specify the companion change — snapshot-aware watermarking with
pg_snapshot_xmin(pg_current_snapshot()) — that must land in the same commit if
a writer ever allocates ahead of its append. Hard gate 1 is widened accordingly:
it appended concurrently across sessions, which share no sequence space and so
exercise none of this, and now appends concurrently within one session as well.
Reversal cost: cheap as prose; moderate once projections exist, since every projection's cursor logic changes at once.
A zero-row guarded update rolls the append back¶
Decided: when an append carries a state change, the writer inspects the
affected-row count of the guarded UPDATE runs before COMMIT and rolls the
whole transaction back when it is zero. An append carrying no state change — the
diagnostic run.fenced — has no guarded UPDATE and is the explicit exception.
Why: the specification previously asserted that a writer losing the
status-and-lease race "commits nothing at all". That is not what the SQL does. A
zero-row UPDATE is not an error and aborts nothing, so the sequence allocation
and the event INSERT would commit without it, producing exactly the
run.completed-while-RUNNING record the same section forbids.
The question: none outstanding; this is a correction rather than a choice. Recorded because it adds a requirement to the append path that no reviewer has seen.
Reversal cost: cheap as prose; the rule is one row-count check at one call site.
No outbox table¶
Decided: event publication uses LISTEN/NOTIFY with no outbox, and every
consumer polls from a watermark.
Why: NOTIFY is transactional — delivered on commit, discarded on rollback
— so the atomicity an outbox provides is already present. What NOTIFY does not
provide is delivery, and a watermark poll covers that more cheaply than a second
table with its own drainer.
Alternative: a standard transactional outbox.
Reversal cost: cheap. Adding an outbox later is additive; consumers that already poll keep working.
Three priority classes, capacity reserved rather than aged¶
Decided: interactive (0), async (10), maintenance (20), with worker slots reserved per class.
Why: the revision summary requires claim-priority ordering and Section 14's body has none. Reserved capacity was chosen over aging because aging makes a run's latency a function of the queue's history, which cannot be reconstructed from a single run's record when explaining an incident.
Alternative: strict priority with aging, or separate queues per class.
Reversal cost: cheap. priority is a SMALLINT, so classes can subdivide
without a migration, and the scheduling policy is worker-side configuration.
max_attempts is 3, and runs.failure is the dead letter¶
Decided: only lease expiry requeues a run; permanent Section 13 classifications fail immediately; there is no separate dead-letter table.
Why: the plan specifies no queue-level retry semantics at all, so some
number had to be chosen. Three is conventional and low enough that a
systematically failing run does not consume a worker for long. Keeping failures
in runs keeps them attached to their own events and checkpoints.
Alternative: a configurable per-priority-class attempt cap.
Reversal cost: cheap — it is a constant and a status value.
Trajectory export is split across Milestone 2 and Milestone 3¶
Decided: the projection scaffold and its watermark are built in Milestone 2; the export itself is Milestone 3.
Why: the plan contradicts itself. Section 21's Milestone 2 Implement list says "Trajectory-export projection scaffold"; Section 21.1's sequencing table places trajectory export in Milestone 3. Both readings are satisfied by the split, and the scaffold is a second consumer that exercises the watermark machinery early, which is worth having.
The question: which of the two statements in the plan is the intended one? The other should be corrected so the document stops disagreeing with itself.
Reversal cost: cheap.
Ambiguous non-idempotent tool executions are reported as UNCERTAIN¶
Decided: a tool left RUNNING by a crash, whose idempotency class does not
permit a safe retry, resolves to UNCERTAIN and is reported to the model as an
unknown outcome rather than as a failure.
Why: "it failed" is a claim the runtime cannot support about a payment that may have been made. Models handle stated uncertainty better than they handle a confident falsehood, and the alternative invites a retry of an operation that may already have succeeded.
Alternative: report as failed, or block the run for human review.
Reversal cost: moderate. It is a tool-protocol outcome value, so changing it later means changing every tool contract and the prompt text that explains it.
Policy and approvals (ADR-0005, ADR-0006)¶
Five undefined types were defined rather than deferred¶
Decided: ProposedAction, ApprovalStatus, SideEffectClass,
RiskLevel, and IdempotencyClass each appear exactly once in the plan, as the
type of a field, and are defined nowhere. All five are now specified.
Why: Milestone 4 cannot start without them, and each one silently decides
what the engine is capable of deciding. Every value was derived from a statement
the plan already makes — the fifteen SideEffectClass values are Section 9.2's
fifteen action categories, and the four IdempotencyClass values are Section
8.4's four crash-recovery bullets — so that nothing was invented that the plan
did not already imply.
Alternative: leave them for the coding agent to decide at implementation time.
Reversal cost: moderate. Renaming a value is cheap now and a migration
later, since three of them are persisted on tool_invocations.
Section 9.2's three non-enum decision strings were resolved as conditions¶
Decided: "Allow with restrictions" and "Allow only in sandbox" become
ALLOW guarded by a predicate, denying when the predicate fails. "Deny
initially" becomes DENY in the default profile, with "initially" read as a
statement about which profile is loaded.
Why: three of the matrix's sixteen cells hold strings that are not
PolicyDecisionType values, so the matrix cannot be looked up by a program as
written. Both readings preserve the stated outcome exactly; no cell's behaviour
changes.
The question: is "Deny initially" about a later profile (a deployment can turn it on) or a later milestone (the default changes for everyone)? The two differ in who holds the switch.
Reversal cost: cheap.
policy_version is a content hash, and rules are files rather than rows¶
Decided: policy_version is {profile}@{profile_sha256[:12]}+h{hardline_
sha256[:8]}. Profiles and hardline rules are version-controlled YAML packaged
in the distribution, loaded once at process start and frozen. The
policy_profiles table records that a ruleset was loaded; it does not store
rules.
Why: the field is declared as a bare str on PolicyDecision with no
producer, format, or storage, and the context engine's ContextPlan already
consumes it. A counter depends on someone remembering to increment it; a stale
counter asserts an equality that does not hold. Section 22 classifies policy
rules as trusted, and a table anyone with a connection string can edit is not a
trust boundary.
The cost, stated plainly: an urgent policy change requires a deploy. It cannot be made from a console.
Alternative: a rules table with an admin UI, or a monotonic counter.
Reversal cost: moderate. The format is embedded in every persisted decision, so changing it later means either a migration or a parser that understands two formats.
Approval was generalized beyond tool calls¶
Decided: ActionKind covers tool calls, memory writes, skill authoring, and
artifact export. approvals.tool_invocation_id widens from NOT NULL to
nullable and a general action_id carries the reference.
Why: Section 30.4 requires skill authoring to be approval-gated and the
memory specs require governance on the write path, but the approval object is
structurally tool-only. The alternative — fabricating a tool invocation for each
non-tool action — puts rows in tool_invocations that no tool executed and
corrupts every metric computed over that table.
Reversal cost: moderate. It is a nullable column and an enum, both cheap before Milestone 4 ships.
The denial tool result deliberately tells the model very little¶
Decided: a denial carries a stable reason_code, a fixed message per code,
and a remediation hint, enforced by a field allowlist. The rule that fired, the
pattern, and the profile name never reach the model. Three identical denied
proposals fail the run.
Why: Milestone 4 requires that "denial becomes a structured tool result" and no shape exists anywhere. The model is a partially trusted consumer under Section 22; naming the rule that blocked it hands it a search gradient. The circuit breaker exists because a model that re-proposes a denied action will do so until the run budget is gone.
The cost: some legitimate self-correction will be slower than it would be with a richer message.
Reversal cost: cheap for the message content; moderate for the breaker threshold, which is a constant.
MEMORY and KNOWLEDGE were placed in Section 22's trust tiers¶
Decided: Section 11.2's seven trust labels map onto Section 22's three
tiers. MEMORY is partially trusted content that cannot authorize;
KNOWLEDGE is untrusted as instructions. Only PLATFORM,
TRUSTED_CONFIGURATION, and USER can authorize anything, and USER only
within the principal's own scopes.
Why: the two lists never map onto each other and two labels had no tier at all, which matters because Section 11.2 makes an authorization claim about trust labels — a tool result must never change approval requirements. Memory is agent-authored from prior conversation, so a belief that the user always approves deletions is a belief and not a grant.
Reversal cost: cheap as prose; moderate once rules key on it.
Self-approval is permitted by default¶
Decided: the principal who started a run may resolve their own approval. Requiring a distinct resolver is available as a rule, not a hardcoded condition.
Why: Section 6.2 anticipates single-user deployments, where there is no one else to ask, and a gate nobody can pass is not a safety control.
Alternative: require a second principal for HIGH and CRITICAL risk.
Reversal cost: cheap — it is a rule in a profile.
Two API endpoints were added because the CLI has no endpoint to call¶
Decided: GET /v1/approvals and GET /v1/approvals/{id}, tenant-scoped
from the authenticated principal and never from a query parameter.
Why: Section 17 requires agent approval list and Section 16 defines only
the resolve endpoint. Section 17 also forbids a second runtime loop in the CLI,
so the command must call an application service through an endpoint that does
not exist.
Reversal cost: cheap.
A second REQUIRE_APPROVAL at revalidation becomes a denial¶
Decided: revalidation after approval voids outright on an argument, scope,
or agent-version change, and re-evaluates only when policy_version changed. If
re-evaluation returns anything other than ALLOW — including a second
REQUIRE_APPROVAL — the call is denied rather than asked again.
Why: Section 9.3's step 8 says to revalidate and does not say what the comparison is or what happens when it fails. Asking twice is defensible, but it admits a loop where a ruleset that always escalates parks a run forever. A denial the user can deliberately retry is better than a pause the system cannot leave.
Reversal cost: cheap.
ADR-0006 was written as already amended¶
Decided: the record states the original prohibition and carries ADR-0007's amendment inside it as decision 5, rather than presenting an unamended decision that four sections of the plan already contradict.
Why: Section 6.8 cites "ADR-0006 as amended" and Section 10.6 records the amendment, but the ADR itself was never written. Writing the pre-amendment version would have created a record that the plan already disagrees with on the day it was committed.
Reversal cost: none — it is a record of a decision already made elsewhere.
Model gateway (ADR-0002)¶
ProviderReasoningItem.trust_level still defaults to PLATFORM¶
Decided: the default was left as Section 6.6 states it, and the spec bounds it instead — the payload is never parsed, never rendered as prompt text, never reaches the policy engine, and never enters memory or a user-facing renderer as trusted content.
Why: PLATFORM is the highest trust tier and the policy engine reads trust
tiers to set restrictiveness, so on its face this gives model output the standing
of platform configuration. AssistantMessage correctly defaults to
EXTERNAL_UNTRUSTED. Fixing the default means editing a plan sentence, which the
conversion constraints forbid doing unilaterally. The four bounding properties
leave the label no consumer able to act on it, which turns a live privilege
inversion into a naming defect.
This is the one item on this page worth reading first. The bounding is sound but it is a fence around a mislabelled thing, and fences are maintained by people who remember why they are there.
Reversal cost: cheap now — it is a default value and four sentences. Expensive after any code reads the field.
server_tool_use is refused rather than mapped¶
Decided: an Anthropic server_tool_use content block raises
ModelProtocolError and fails the attempt in 0.1.
Why: Section 10.2 has no row for it. A tool executed inside the provider never passes the policy engine, so mapping it onto an ordinary tool call would admit a class of side effects with no policy decision. We never request server tools, so the refusal is unreachable in normal operation and exists to make the day someone enables one loud rather than quiet.
Alternative: a configuration flag permitting specific server tools with an explicit policy mapping. That is the right eventual answer and it needs a policy design first.
Reversal cost: cheap.
Retry ownership splits on whether output was already emitted¶
Decided: the adapter retries only before the first event reaches the caller,
at most three times; after any output it fails and the caller decides.
max_attempts = 3 lives in application code.
Why: plan line 1233 puts retries in the adapter and line 1556 says to keep retry decisions in application code "not in provider adapters alone". The word alone implies a split and does not say where. Pre-output failures are invisible and safely repeatable; post-output failures need to know whether partial output was shown to a user, which only the caller knows.
Reversal cost: cheap.
UsageEvent was made advisory rather than terminal¶
Decided: UsageEvent may appear zero or more times, carries provisional
figures, and is always superseded by ModelCompletedEvent.turn.usage.
Why: Section 10.2 lists UsageEvent in the neutral vocabulary with no OpenAI
source row, and Section 10.4 requires exactly one completed-or-failed event per
attempt. Making usage advisory resolves both at once: OpenAI emits none,
Anthropic emits an early one for live cost meters, and neither competes with the
terminal event.
Reversal cost: cheap.
Provider pinning was resolved temporally, not architecturally¶
Decided: provider selection happens once at run start and the pin is absolute and persisted for the life of the run. Milestone 10's availability routing chooses the pin; it never re-routes a live run. A provider outage fails a pinned run.
Why: Section 10 line 1266 requires pinning and Milestone 10 line 2722 wants availability routing, which read as a contradiction. They are not, if selection and execution are separated in time. Mid-run switching would either discard provider continuation state invisibly or attempt a translation that cannot be done.
Question for Andy: is failing a run on a provider outage acceptable, or should a run be allowed to restart against a different provider from its last checkpoint? The second is a real option and it is a different feature.
Reversal cost: moderate — the pin is a persisted column.
Two schema tables were added for usage¶
Decided: model_calls (one row per attempt) and model_prices
(append-only). runs.usage keeps its shape and becomes a rollup maintained in
the same transaction.
Why: Section 15 has no usage table and runs.usage JSONB cannot answer which
attempt burned the tokens, which is the question a run that retried three times
raises. Section 6.5's cost precedence needs a place to record which source a
figure came from.
Reversal cost: moderate — a migration that has not been written yet.
Failed attempts count against budget¶
Decided: budget is checked before each attempt using usage that includes
failed and crashed-out attempts, and a run that exhausts its budget this way
stops with BUDGET_EXCEEDED.
Why: Section 12.3 accepts duplicate provider cost on crash recovery and
Section 6.5 enforces max_cost; together they permit a crash-looping run to
overspend on work it never kept. Charging failed attempts makes the overspend
visible to the check rather than after the invoice.
Reversal cost: cheap.
Model-call timeouts were invented¶
Decided: ModelRequest.timeout_seconds defaults to 600 and
stream_idle_seconds to 60.
Why: no document defines a model-call timeout, while ToolSpec has had one
since Section 8. Without them a hung connection stalls a run until the worker
deadline fires, if it has one. The idle timeout is the load-bearing half.
Question for Andy: 60 seconds between events is comfortable on both vendors today, including during extended thinking, but it is a guess until real traces exist.
Reversal cost: cheap — both are configuration.
The model registry is a hashed file, not a table¶
Decided: the model registry is a YAML file per provider profile, validated
and hashed at load, with registry_version recorded on every attempt.
Why: it matches how the policy profile is treated, keeps 0.1 free of another migration, and makes a run reproducible against the exact catalogue it resolved against.
Alternative: a database table, which would allow per-tenant model catalogues sooner.
Reversal cost: moderate.
The contract suite runs against five adapters, not the one the fixtures name¶
Decided: fake, recorded, OpenAI, Anthropic and chat_completions, with the
suite written against the fake before any real adapter exists.
Why: Milestone 3 line 2202 names OpenAI fixtures only and line 2430 requires the suite against three providers. The acceptance criterion is treated as controlling and the fixture list as an incomplete enumeration. Writing the suite against the fake first is what stops it being shaped around whichever provider was implemented first.
Reversal cost: cheap.
Unknown provider stream events are ignored rather than fatal¶
Decided: an unrecognized event type is logged once per process per type and skipped.
Why: providers add event types without warning, and a strict adapter breaks on a vendor deploy we did not perform. Logging once keeps the addition visible in telemetry without one line per token.
Reversal cost: cheap.
Reasoning display is filtered per session¶
Decided: ReasoningDeltaEvent is published to the live transport only for
sessions with reasoning display enabled.
Why: reasoning is the highest-volume event class and the least often wanted. Per-session is more flexible than per-tenant and marginally more work.
Reversal cost: cheap.
Tool system and MCP (ADR-0021)¶
mark_effect_sent is a tool-author obligation, not a type-checked one¶
Superseded: ADR-0040
adopted executor-owned conservative watermarking before invoking any tool with
a non-NONE side effect. The original proposal below is retained as history.
Decided: a NON_IDEMPOTENT or CONDITIONALLY_IDEMPOTENT tool must call
ctx.mark_effect_sent() immediately before its first outbound operation. The
contract suite asserts it for every registered tool against a fake target, and
the executor records tool.contract.no_watermark when a tool returns ok
without having called it.
Why: the alternative is wrapping every outbound call in a decorator the framework controls, which only works if the framework knows what "outbound" means — and for an MCP tool, a sandbox command, or a device call it does not. A contract the suite checks is weaker than a type, and stronger than the current design, which has no mechanism at all.
Alternative: make the executor write the watermark unconditionally before
step 10, which is safe but marks every call, so every interrupted call becomes
UNCERTAIN again and the column buys nothing.
Reversal cost: cheap now, moderate after tools exist.
UNCERTAIN is reserved for non-idempotent calls with a set watermark¶
Decided: the recovery table re-executes everything else, including a
non-idempotent call whose watermark is NULL.
Why: it is the whole point of the column. The risk is a tool that performs an effect before calling the method, which is a contract violation the suite catches, versus the current behaviour where every crash during a consequential call reaches a person.
Reversal cost: cheap — deleting one row of the table restores today's pessimism.
A batch of parallel tool calls admits or rejects as a whole¶
Decided: if any call in a batch fails the five conditions, the entire batch runs sequentially. No splitting into a parallel read group and a sequential write group.
Why: splitting requires deciding that no read in the first group depends on a write in the second, and Section 12.4's closing sentence is a warning against exactly that inference. The cost is latency on mixed batches.
Reversal cost: cheap.
Operators classify MCP servers, and an unclassified server is maximal risk¶
Decided: side_effect, risk, idempotency, and required_scopes are
per-server operator configuration. The default is EXTERNAL_WRITE, HIGH,
NON_IDEMPOTENT.
Why: MCP has no field for these, and a field would be a claim by the party the classification constrains. Per-server rather than per-tool because the configuration surface is a place to move slowly.
Alternative: per-tool overrides, which is one more column and is the obvious next step once a real server makes the coarseness hurt.
Cost of this choice: onboarding an MCP server is now a deliberate act with a classification step, and a server with one destructive tool makes its nine harmless ones expensive. This will be felt as friction.
Reversal cost: cheap.
Tenant-configured MCP servers are HTTP-only; stdio is operator-only¶
Decided: a tenant may not name a stdio command.
Why: a stdio server is a child process of the worker, so a tenant-supplied command line is remote code execution in the worker's trust zone. This one is not really a judgement call, but it is a restriction Milestone 8 does not state and someone will ask why their local server will not connect.
Reversal cost: none; it should not be reversed.
MCP resources become a tool, not a context source¶
Decided: one synthetic mcp.{server}.read_resource per server with a
resources capability. Milestone 8 says to map resources to "the context-source
abstraction", and this maps them to the tool abstraction instead.
Why: an automatic context source puts externally controlled text into assembled context on a schedule the platform does not control, in the region ADR-0020 keeps stable. Making the model ask means the request passes policy, size limits, and trust labelling like anything else. This is the one place this document knowingly reads a milestone line differently from its literal wording, and it is flagged here for that reason.
Alternative: implement the context-source mapping with a per-server allowlist of URIs. More faithful to the milestone text, more surface.
Reversal cost: moderate.
Sampling and roots are declined at capability negotiation¶
Decided: the client does not advertise them, so a server requiring them fails to connect.
Why: sampling is an external party spending a tenant's model budget on a prompt the platform did not compose. Declining at negotiation rather than at request time means a server never gets to try.
Reversal cost: cheap for roots, expensive for sampling — it would need its own budget, policy, and context rules.
A server's catalog change is recorded and not applied mid-session¶
Decided: tools/list_changed emits mcp.catalog.changed and does nothing
else until the next session opens.
Why: applying it hands an external server the ability to invalidate a tenant's byte-stable prefix at will, which is unbounded cost imposed by a third party plus a cache-timing channel. It generalizes ADR-0020's existing rule.
Cost: a genuinely new tool is unavailable until the next session.
Reversal cost: cheap.
The excerpt split is 60% head, 20% tail¶
Decided: a truncated result shows the first 60% and last 20% of the byte budget with an explicit elision marker, and the whole output becomes an artifact.
Why: most large tool outputs are logs whose beginning says what ran and whose end says whether it worked. It is a guess and the eval harness should measure it.
Reversal cost: cheap.
approval_hold_seconds defaults to 300¶
Decided: the bridge blocks for up to five minutes on an in-script approval, then tears the sandbox down and re-executes the script from the start on resume, deduplicating the calls that already ran.
Why: long enough for an approver at their desk, short enough that a held sandbox is not a leak. Per-tenant configurable.
Reversal cost: cheap.
Replay safety for orchestration scripts is conditional on determinism¶
Decided: stated as a caveat in the document rather than engineered around.
Why: a script that branches on the clock or on changed tool output issues a different call, derives a fresh key, and executes for real — which is correct, but it means replay does not guarantee no duplicate work, only no duplicate identical work. Engineering around it would mean recording and replaying the script's own control flow, which is a much larger project than the bridge.
Reversal cost: n/a — this is a documented limit, not a decision to undo.
Events carry classification; rows carry payload¶
Decided: no tool event contains arguments, results, error detail, or external text.
Why: the event stream is replayed to SSE consumers, retained on a different schedule, and exported to observability stacks. Tenant data in three places with three retention policies is a compliance problem waiting to be discovered.
Cost: the SSE stream stops being useful for debugging a failing tool call.
Reversal cost: cheap now, expensive once events are retained.
No tool_registry_snapshots table¶
Decided: the context plan's tool_names and tool_schema_sha256, plus the
mcp_tool_catalog history, answer what a session advertised.
Why: a third record of the same fact is a third place for it to disagree.
Reversal cost: cheap.
Evaluation harness (ADR-0001, ADR-0022)¶
ADR-0001 was written now, and the runtime-loop ADR moves to 0023¶
Decided: docs/adr/0001-modular-monolith.md was written as part of this
task rather than left as a gap, and the evaluation harness took ADR-0022. The
runtime-loop ADR planned as 0022 becomes ADR-0023.
Why: Section 4's repository layout names 0001-modular-monolith.md, the ADR
index carried an apologetic paragraph explaining its absence, and twenty ADRs
referred to a foundational decision documented nowhere. It also had real content
to add rather than being a formality: Section 5 closes its fourteen dependency
rules with "verify these constraints where practical", and the harness spec's
structural-gate mechanism is what lets "where practical" be resolved rule by
rule. Writing it alongside the harness is what made it worth writing at all.
Alternative: fold the monolith decision into ADR-0022 and leave 0001 unused. Rejected because the two decisions have different lifetimes — the architecture outlives any particular harness — and a permanently skipped 0001 invites the question forever.
Reversal cost: cheap now; the renumbering touches two files. Expensive once anything outside this repository cites ADR-0023.
The harness is treated as larger than the case runner¶
Decided: Section 20's harness is read as including a gate registry, property gates, corpus gates, and structural gates, not only the YAML case runner it describes.
Why: the seven specs declare roughly forty-nine hard gates and name Section 20 as their enforcer, but Section 20's sixteen assertion types can express perhaps eight of them. Either the gates are decoration or the harness is bigger than the case format. Nothing in Section 20 was changed; four gate kinds were added around it.
Cost: the harness becomes a Milestone 0 deliverable rather than a Milestone 1 one, and it is more machinery than the plan implies.
Reversal cost: cheap as prose. Expensive as a decision, because the alternative reading makes six spec documents' closing sections unenforceable.
Roughly a third of the declared gates are not case gates¶
Decided: each spec's gates were sorted into the four kinds, and the per-spec counts are published in the harness document as a table.
Why: the counts are the useful output, not the individual assignments. They say which harness facility each spec needs, and they say that a case-only harness would report green with a third of the plan's stated invariants unchecked.
Caveat worth your eye: the individual assignments are a judgement call in a handful of cases, particularly where a gate could be written either as a parameterized case sweep or as a property. The counts are robust to those; the row-level assignments are not authoritative.
Reversal cost: cheap.
There is no test mode¶
Decided: no environment variable, no configuration flag, and no
if settings.testing branch in the policy engine, the approval service, or the
tool executor. Evaluation identity is data: a tenant_eval tenant created by an
ordinary migration, named principals, and policy profile files loaded by the
production loader and subject to the same totality gate.
Why: a flag that disables the gate is a code path in the shipped binary and
is therefore reachable from production. ADR-0005's third gate says exactly one
function performs the PROPOSED to AUTHORIZED transition, and a test bypass
would be a second one.
Cost: setting up an evaluation is more work than flipping a flag, and the production loader gains a startup check that must be maintained.
Reversal cost: cheap now. Expensive after Milestone 4, since the alternative is a bypass that would then have to be removed from working code.
The eval tenant is created by a migration in every deployment¶
Decided: unconditionally, in production too, with the production loader check as the thing standing between an eval profile and a production process.
Why: a migration that runs only outside production introduces a schema difference between environments, which is its own class of bug and a particularly annoying one to diagnose.
This is the open question most worth your answer. The alternative is defensible and I do not have a strong view. Recorded as open question 1 in the harness document.
Reversal cost: cheap now, moderate after the migration ships.
interventions was added to the case schema¶
Decided: six of them — approve, deny, cancel, kill the worker, answer, and disconnect — fired at named points in a run.
Why: Section 20.3 requires cases for approval requested, approval granted and resumed, approval denied, cancellation, restart after checkpoint, restart after an idempotent success, the ambiguous non-idempotent call, and SSE replay after disconnect. None of them is writable in a format whose only input is the opening user message. This is an addition to Section 20.1's format, not a change to it; every field it already had is unchanged.
Reversal cost: cheap.
Case 18 was split into 18a and 18b¶
Decided: kill before the watermark asserts re-execution; kill after asserts
UNCERTAIN, a human-review row, and that the model is told the outcome is
unknown rather than that the call failed.
Why: the case was untestable while "ambiguous" was an undefined word, and
ADR-0021's effect_sent_at made it two cases. Only the second has a safety
consequence — telling a model a non-idempotent write failed is the fastest route
to a duplicate write.
Note: this makes the count twenty-six, not twenty-five. Section 20.3 says "at least these twenty-five", so the floor is respected.
Reversal cost: cheap.
resilience was named as the sixth test category¶
Decided: Section 20.4 lists five categories; a sixth is named here.
Why: Section 4's repository layout already has tests/resilience/, the
event-log spec places its kill-the-worker recovery test there, and the tool
system spec calls its crash-recovery gate "a resilience test". The category
existed in the layout and in two specs' expectations; only the list omitted it.
This is reconciliation, not a new requirement.
Reversal cost: cheap.
The capability track defaults to five repeats¶
Decided: five, not one.
Why: a single run of a stochastic system against a rubric produces a number of unknown variance, and comparing two such numbers across a release is how an evaluation programme generates false alarms until people stop reading it.
Cost: five times the track's spend, and the number is chosen for variance estimation rather than measured against this suite's actual spread.
Reversal cost: cheap — it is a default. Recorded as open question 2.
A ceiling hit is excluded from the score, not scored zero¶
Decided: a scenario that exhausts its cost ceiling records ceiling.hit,
is excluded from the distribution, and is counted separately.
Why: it conflates "we stopped paying" with "the agent failed", and it does so in the direction that corrupts the distribution most. A rising ceiling-hit rate is its own signal, usually of the agent becoming less efficient rather than less capable.
Reversal cost: cheap.
Judges are versioned as a unit and cross-version comparison is refused¶
Decided: a judge is a model, a prompt, and a rubric under one identifier, pinned to a provider version, replaced only alongside a bridge run publishing a calibration offset, never reusing an identifier after deprecation. The tooling refuses to compare scores across judge versions rather than footnoting them.
Why: a judge change and a subject change are indistinguishable in the resulting score. A footnote is not read at the moment the comparison is made.
Cost: the first published score waits for judge governance to exist.
Reversal cost: cheap now; expensive after scores are published, since the comparability of the historical series depends on it.
Trajectory conversion replays tool results rather than re-executing tools¶
Decided: each recorded tool result becomes a fixture keyed by tool name and
normalized argument hash; a converted case that reaches a call with no recorded
result fails with fixture.missing_tool_result.
Why: the real run called a real tool against a real system, and the case must not. The failure is the correct outcome — it means the model behaved differently from the recording, and the case has nothing to say about what happens next.
Reversal cost: cheap.
A converted case needs a human to write its assertions¶
Decided: it carries source: trajectory and does not enter the blocking
suite until reviewed.
Why: auto-generated assertions assert whatever the system did on the day it was recorded, including its defects. There is no worse outcome for a regression suite than pinning a bug as expected behaviour.
Cost: the conversion is not push-button, which is most of its appeal.
Reversal cost: cheap.
Quarantine expires after fourteen days whether or not the test was fixed¶
Decided: flaky tests are retried once with the retry rate reported even while green, quarantined on a second failure within thirty days, and released from quarantine automatically after fourteen days. Gates may never be quarantined.
Why: a quarantine with no expiry is a delete with extra steps, and the tests that end up there are disproportionately the ones covering concurrency and recovery — exactly the ones worth fixing.
Cost: a build that goes red on a schedule rather than on a change, which is annoying by design.
Reversal cost: cheap.
The deterministic suite adds no tables¶
Decided: the capability track gets eval_scenario_runs and
eval_criterion_scores; the deterministic suite persists nothing beyond the
ordinary event log it already writes as tenant_eval.
Why: the suite's output is a CI result, and a table would be a second record of it that can disagree with the first.
Reversal cost: cheap.
Gate 7 is asserted by blocking egress, not by not configuring a key¶
Decided: the definition of done's eighteenth item — a deterministic suite that runs in CI without an API key — is asserted by running the integration job with network egress blocked.
Why: "without requiring an API key" is usually implemented as "we did not configure one", which stays true until a fixture falls through to a real client and the failure is a confusing timeout rather than a clear one.
Cost: the CI job needs a network policy, which is a small amount of infrastructure work in Milestone 0.
Reversal cost: cheap.
Runtime loop (ADR-0023)¶
The loop was split into a loop and an executor¶
Decided: run_loop computes a RunOutcome and performs no terminal action;
a single finalize in runtime/executor.py does every transition, lease
release, and terminal event append, for all five outcome kinds. A structural
gate asserts that RunRepository.transition and RunQueue.release are
reachable from exactly one module.
Why: Section 12.1's suspension path returns bare, which under Section 27.2
leaks the lease of every run that pauses for approval. The minimal fix is a
finally, which closes today's paths and leaves the next return free to
reopen the hole, and offers nothing to gate against.
Cost: a run's ending is spread over two modules that must be read together.
Reversal cost: moderate. Inlining finalize back into the loop is
mechanical; the gate would have to be retired with it.
Step became a value object with a column, not a table¶
Decided: model_calls gains step_number INTEGER NOT NULL; there is no
steps table.
Why: every step makes at least one model call, so a steps table would
duplicate model_calls row for row and need a consistency rule against it. The
column also makes steps that produce no tool calls visible, which they are not
today.
Reversal cost: cheap before Milestone 2; a migration after.
Nine fields were added to Run¶
Decided: tenant_id, agent_id, agent_version, lease_epoch,
attempts, priority, scheduled_for, deadline_at, failure.
Why: six are columns the event-log spec already introduced and the domain
model never reflected, so this is bookkeeping. Three are new: agent_id and
agent_version because Section 12.1 reads them off Run and Section 6.3 puts
them on Session, and deadline_at because RunLimits declares it as a value
and the lease sweep needs it as an indexable column.
Reversal cost: cheap; they are additive and nothing reads them yet.
The agent version is pinned at run creation¶
Decided: agents.get_version is called once, when the run row is created,
and the result is never re-resolved — including across a suspension that lasts
days.
Why: an approval is granted against a specific agent's proposed action. A deploy landing between the request and the approval would otherwise change what the approver authorized.
Cost: a long-paused run resumes on an agent version that may since have been withdrawn. The alternative is worse.
Reversal cost: cheap.
A child-run wait reuses WAITING_FOR_APPROVAL¶
Decided: suspension has three kinds — approval, user, child run — and the
third maps to WAITING_FOR_APPROVAL rather than to a new WAITING_FOR_CHILD
status. Recorded as open question 2 in the spec.
Why: the wait behaves identically in every respect that the state machine
cares about — lease released, no worker slot, resumed by an external event — and
a fourth non-terminal status means amending four documents that enumerate
RunStatus exhaustively.
Cost: an operator looking at a stuck run sees a status that does not mean what it says. This is the decision in this document least likely to survive Andy's review, which is why the suspension kind is a typed field rather than inferred.
Reversal cost: moderate, and rising. Cheap now; expensive once the status has API consumers.
Cancellation was split across three milestones¶
Decided: the token and the loop's observation points land in Milestone 1, the tool-executor points in Milestone 4, and the API surface and sandbox propagation in Milestone 5, where Section 21 places cancellation.
Why: Milestone 5's acceptance criterion is "cancellation reaches the worker", which is the API half. The token is three lines and threading it later means threading it through code that assumed it did not exist.
Cost: a partially useful mechanism exists for four milestones.
Reversal cost: cheap to collapse back into Milestone 5; expensive to introduce late, which is the asymmetry the decision rests on. Recorded as open question 1.
"After every operation" was read as "record in the same transaction"¶
Decided: budget has three scopes — run, step, attempt. Section 6.5's "check limits before and after every model or tool operation" is implemented as a check before and a record after, where recording usage and evaluating the limit are one operation in one transaction.
Why: a literal second check after the operation and a separate write leaves a window in which the run is over budget and no query would say so. ADR-0002's per-attempt rule and Section 6.5's per-operation rule are then both satisfied without a third granularity.
Reversal cost: cheap.
The heartbeat also owns the deadline and the cancellation poll¶
Decided: one supervisor task at a third of the lease interval renews the
lease, checks deadline_at, and polls for a cancellation request.
Why: three timers and three queries for three questions that are all "has the outside world changed its mind", on a run that is otherwise blocked on a provider stream.
Cost: cancellation latency is bounded by the heartbeat interval rather than being immediate.
Reversal cost: cheap.
A fenced worker aborts its in-flight stream¶
Decided: on heartbeat returning False, the worker cancels the model
stream, appends exactly one run.fenced event, and writes nothing else.
Why: nothing the stream produced could be committed under a stale epoch, so finishing it spends tokens on an uncommittable result. The single append is legal because the event log is sequence-guarded rather than epoch-guarded.
Cost: the output that would have explained the stall is lost. Recorded as open question 6.
Reversal cost: cheap.
Compaction was capped at two attempts per step¶
Decided: build_with_pressure measures, compacts, and measures again, up to
MAX_COMPACTIONS_PER_STEP = 2, then raises a permanent ContextOverflow.
Why: an uncapped loop against a compactor that cannot shrink the body — a single tool result larger than the budget — spins on a model call per attempt. Two is enough for the case where one pass narrowly misses.
Reversal cost: cheap; it is a constant.
The full-snapshot interval is every eighth checkpoint¶
Decided: a checkpoint is full at version 1, every eighth version, and on compaction, suspension, and termination.
Why: it bounds delta reconstruction at seven reads. The number is otherwise arbitrary and is recorded as open question 4.
Reversal cost: cheap.
The evaluation harness's tool event names were corrected¶
Decided: the approval case's event_order now reads tool.call.proposed,
tool.call.authorized, tool.call.completed. It previously read
tool.proposed, tool.authorized, tool.succeeded.
Why: those three names are not events. Section 6.8 declares the tool.call.*
family and ADR-0021 adds no tool event, so the case as written would have failed
against a correct implementation. This is a correction to a document written
during this run, not a change to a requirement of Andy's.
Reversal cost: none; it was a defect.
27.5's "reject or queue" resolves to reject¶
Decided: a message to a session with an active run returns
ConflictError and HTTP 409, except where the active run is
WAITING_FOR_USER, in which case the deterministic routing rule sends the text
to that run's input endpoint.
Why: Section 27.5 permits either, but ADR-0004's partial unique index on non-terminal runs per session makes queueing impossible at the database level as currently specified. Choosing "queue" would silently require a different constraint.
Cost: a client that submits during a long run gets an error rather than a promise.
Reversal cost: moderate; queueing later costs a migration rather than a redesign.
The maintenance sweeps run everywhere under advisory locks¶
Decided: lease expiry, approval expiry, and deadline enforcement run in every worker process, each guarded by a PostgreSQL advisory lock, rather than in a dedicated reaper deployment.
Why: a singleton is an operational burden and a single point of failure for three time-based obligations, one of which is a safety property.
Cost: every node does a little wasted work every interval. Recorded as open question 5.
Reversal cost: cheap.
An empty terminal turn is retried rather than returned¶
Decided: a model turn with no content and no tool calls is treated as a
failed step, retried under Section 13's rules, and fails the run with
EmptyModelTurn on exhaustion.
Why: returning it completes the run with an empty final message, which is indistinguishable to a user from the agent having nothing to say.
Cost: a second context assembly on a model that just produced nothing. Recorded as open question 3.
Reversal cost: cheap.
This document adds no event types¶
Decided: the spec consolidates the fourteen run-lifecycle events introduced
elsewhere and assigns owners to the three that had none — run.claimed and the
two run.waiting_* events — without introducing any.
Why: the event vocabulary is a compatibility surface with the projections, the SSE transport, and the trajectory export. Adding to it from the last spec written would be the least reviewed change in the corpus.
Reversal cost: not applicable.
Deferred questions from earlier specs, now decided¶
These three were left explicitly open by specs written earlier in this run. The mandate was to decide rather than to leave the plan with holes in it, so each is decided, recorded here, and reflected in the spec that raised it.
The compaction summarizer resolves through ModelRouter under a compaction policy¶
Decided: the summarizer's model is not fixed in the context-engine spec and
not chosen at the call site. It resolves through ModelRouter under a named
compaction model policy, defaulting to the run's own provider at the cheapest
tier whose context window admits the region being summarized. The prompt is a
versioned asset and its version is recorded on the checkpoint compaction writes.
Why: defaulting to the run's provider keeps ADR-0002's provider pinning intact — a compaction mid-run would otherwise send conversation content to a second vendor without anything having decided that. Recording the prompt version on the checkpoint is what makes a compaction-fidelity regression attributable to a prompt change rather than to a model change; without it the eval can detect the regression and not locate it.
Cost: one more model policy to configure, and a prompt-asset versioning convention that did not otherwise exist.
Reversal cost: cheap. The tuning question the spec deferred is still deferred to the fidelity eval; only the location of the choice is fixed.
Skill bodies go in Region B and are sticky for the session¶
Decided: Section 30.4's skill instructions are assigned to Region B, and a selected skill's body stays in the prefix for the remainder of the session unless a control tool deselects it.
Why: Region B was already the right region on volatility grounds; the objection the spec recorded was the caching consequence of a large body in a mid-session layer. Stickiness answers it directly. A body that entered and left on alternating steps would invalidate the cached prefix on each of them, which costs more than carrying an unused body for the rest of the session.
Cost: a session that touches many skills accumulates their bodies until the budget forces compaction. The class caps bound this; it is not unbounded.
Reversal cost: cheap until skills ship in Milestone 8.
The temporal entity graph is post-0.1, not an open question¶
Decided: it is recorded as post-0.1 work with nothing in Milestones 0 through 10 depending on it, rather than left as an unscheduled open question.
Why: Milestone 9 delivers beliefs with scopes, provenance, decay, and contradiction handling, and that is what the retrieval path consumes. An entity graph with valid-time and transaction-time edges is a second storage model over the same evidence, and a second model needs a consistency rule against the first. Leaving it "not yet specified" invites an implementer to build a partial version of it inside the belief store, which is the outcome worth preventing.
Cost: temporal queries — "what did I believe about X in March" — are not answerable in 0.1 beyond what belief history gives.
Reversal cost: cheap; it is additive whenever it is taken up.
Cross-document defects found at readiness review (2026-07-25)¶
RunStatus is seven members and there is no WAITING_FOR_CHILD¶
Decided: RunStatus is a StrEnum with QUEUED, RUNNING,
WAITING_FOR_APPROVAL, WAITING_FOR_USER, COMPLETED, FAILED, and
CANCELLED, values equal to their names in uppercase. A run blocked on a child
run waits in WAITING_FOR_USER with a suspension record naming the child.
Why: the type was referenced by five documents and declared by none, which
is the defect a coding agent hits within the first hour of Milestone 1. Seven
is the smallest set that covers every transition finalize can perform. An
eighth state for child runs was rejected because nothing outside the suspension
record distinguishes it: the queue treats all three waiting states identically,
and the resume path already dispatches on suspension kind rather than status.
Cost: a query for "runs blocked on a child" reads the suspension record rather than filtering on status alone.
Reversal cost: moderate. Adding a state after Milestone 2 means a migration
plus every status IN (...) predicate, of which the partial index below is one.
runs gains four columns the domain fields always implied¶
Decided: tenant_id UUID NOT NULL, agent_id UUID NOT NULL,
agent_version TEXT NOT NULL, deadline_at TIMESTAMPTZ NULL, and a partial
index on deadline_at restricted to the three live statuses.
Why: every one of these is read by code the plan already requires — tenant
scoping on every query, the agent version pinned for reproducibility, the
deadline sweeper — and none of them had a column. deadline_at is nullable
because a run without a deadline is normal; the other three are not, because a
run without a tenant is not a run.
Reversal cost: moderate; it is a migration once Milestone 2 has shipped.
The step's identity is model_calls.step_number, not a new column¶
Decided: the canonical step identity is the existing
model_calls.step_number. tool_invocations.step_number remains a foreign
reference to it, not a second source of truth.
Why: an earlier passage in runtime-loop.md claimed step_number lived
only on tool_invocations, which the model gateway spec had already falsified.
Only model_calls is written for every step: a step that proposes no tool calls
writes no invocation row at all, so numbering from tool_invocations would skip.
Choosing the column that always exists means the identity is chosen rather than
invented.
Reversal cost: cheap. Both columns already exist; this fixes which one the prose points at.
tool_invocations.origin_trust is NOT NULL¶
Decided: the column is NOT NULL. A call the runtime issues itself — a
control tool, a maintenance sweep — carries PLATFORM.
Why: tool-system.md declared it nullable and policy-and-approvals.md
declared it NOT NULL, and the authorization record is the wrong place to leave
that ambiguous. A nullable column would mean "policy did not compute the origin
trust", which is the one thing an authorization row must never be able to say.
Every path that writes the row runs after a policy decision, so a value always
exists.
Reversal cost: cheap now, moderate after Milestone 4.
The column is idempotency_class, not idempotency¶
Decided: tool_invocations carries idempotency_class TEXT NOT NULL.
ToolSpec.idempotency and mcp_servers.idempotency keep their existing names.
Why: the same table already carries idempotency_key, and two columns
differing by one suffix is exactly the confusion crash recovery cannot afford —
the recovery path reads one to decide whether replaying is safe and the other to
decide whether it is the same call. The field on ToolSpec sits in a different
namespace where no key is present, so renaming it would cost clarity rather than
buy any.
Reversal cost: cheap; the table is not yet created.
The event catalog is fifty-one types and is now closed¶
Decided: runtime-loop.md carries the full catalog: the twenty-four named
in Section 6.8 plus twenty-seven introduced by later specs, including the seven
MCP and bridge events from tool-system.md and the six memory events from the
formation and retrieval specs. Nothing new is introduced by the runtime loop
itself.
Why: the consolidated list had gone stale at twenty-four while five specs added to it, so a reader had no single place to learn the vocabulary and no way to notice a name being coined twice. Stating the total makes the next addition visible: a spec that adds an event now has to move the count.
Cost: the catalog is a second place to edit when an event is added.
Reversal cost: cheap.
The harness asserts run.queued and approval.resolved¶
Decided: the approval_granted_resumes_run case's event_order asserts
run.queued and approval.resolved, replacing run.created and
approval.granted.
Why: neither replaced name is in the catalog. A golden case that asserts a
non-existent event name fails on the day it is first run, and the failure looks
like a runtime bug rather than a typo in the fixture. approval.resolved
carries the outcome in its payload, which is why one name covers grant and deny.
Reversal cost: cheap.
The gate id is gate.policy.prompt_is_not_authorization¶
Decided: the long registry spelling is canonical; the short
gate.policy.prompt_not_auth used in one illustrative CLI listing was wrong and
the listing was reflowed to fit the real id.
Why: gate ids are matched exactly by the harness and quoted in CI output, so a second spelling is a gate that silently never runs. The registry spelling wins because it is the one the harness loads.
Reversal cost: cheap.
The plan's superseded port signatures are annotated, not deleted¶
Decided: RunRepository.claim_next and RunRepository.heartbeat stay in
Section 7 of engineering-plan.md, with a paragraph above them recording that
ADR-0023 supersedes both with RunQueue.claim and RunQueue.heartbeat, and
that RunQueue is what gets implemented.
Why: the standing instruction is that conversion is additive and existing requirements are not rewritten. Deleting the two signatures would be the cleanest-looking fix and would also destroy the record of what was replaced, which is the part a reviewer needs. Annotating leaves the plan readable as history and unambiguous as instruction.
Reversal cost: cheap.
Two citations pointed at the wrong subsection¶
Decided: two references in model-gateway.md that cited "Section 10.3" for
the normalized request now cite Section 10.1. The two other 10.3 references in
the same file are correct — they point at the fake provider — and were left
alone.
Why: §10.1 is Normalized request and §10.3 is Fake provider. A wrong cross-reference in a spec that is meant to be copy-ready sends the implementer to the wrong requirement.
Reversal cost: cheap.
Bootstrap and composition (ADR-0024)¶
DATABASE_URL is invented¶
Decided: Settings.database_url reads DATABASE_URL, a
postgresql+asyncpg:// DSN, required in every deployment and validated
before anything is constructed.
Why: the plan makes PostgreSQL the source of truth, ships a Docker
Compose file, and requires make migrate — and names no environment
variable for reaching the database. It names exactly three environment
variables in total, none of them this one. Something has to carry the
connection string, it fails the "can be committed" test, and every
alternative I could see was worse: a committed YAML file with a password
in it, or a hostname/port/user/password quartet that has to be
reassembled. DATABASE_URL is what SQLAlchemy, Alembic, and the compose
file already expect.
Reversal cost: cheap. It is one field and one name.
.env.example is not a list of all 106 configuration knobs¶
Decided: the Milestone 0 definition-of-done item "New configuration
appears in .env.example" is read as applying to the environment layer
only. A value is an environment variable if and only if it differs
between two deployments of the same revision and cannot be committed.
That test leaves eight fields in Settings; the other knobs live in
committed YAML.
Why: the literal reading makes all 106 knobs environment variables,
which contradicts policy_version — defined as a hash of the profile
file and the hardline file and recorded on every policy decision. If an
environment variable could change an effective rule, that hash would not
change with it, and the audit trail would be false rather than merely
stale. The literal reading also contradicts Section 15's own sentence
that policy rules are version-controlled files, not rows. This is the
single largest interpretive decision in the document and the one most
worth confirming.
Cost: changing a knob for one deployment now means committing a file or adding a named interpolation point.
Reversal cost: moderate. Widening the environment layer later is additive; narrowing it after operators depend on an override is not.
The environment never overrides a file — it is interpolated into one¶
Decided: configuration is three layers: shipped defaults, an optional
operator overlay directory, and ${VAR} interpolation at points the YAML
names explicitly. Only the first two are a precedence chain. There is no
AGENT__POLICY__DEFAULT__X=1 style override path.
Why: Section 10.5 already writes model: ${OPENAI_MODEL}, so the
interpolation form is the plan's own precedent; this generalizes it and
forbids the other. The conventional twelve-factor arrangement, where the
environment wins over files, is the specific thing that would make
policy_version unfalsifiable. Interpolation keeps the secret out of the
file while leaving the file the thing that gets hashed.
Reversal cost: high. This is the decision the audit story rests on.
The overlay merges by top-level key, not deeply¶
Decided: AGENT_CONFIG_DIR is merged file by file over the shipped
defaults, and within a file, by top-level key. An overlay that defines
model_policies replaces the whole mapping rather than merging into each
policy.
Why: deep merge makes the effective document hard to predict from the two inputs, and the effective document is what gets hashed. Replacing a whole top-level key is legible in a diff. The cost is verbosity when an operator wants to change one nested value.
Reversal cost: moderate — deep merge is a superset, but overlays written against shallow merge would then change meaning.
Configuration YAML lives beside the package that owns it¶
Decided: six files under src/agent_core/, each next to the module
that reads it, rather than a top-level config/ directory.
Why: the plan set exactly one precedent, policy/hardline.yaml, and
it is beside its package. A top-level config/ would also collide
confusingly with the config.py module Section 4 names, and it separates
a default from the code that reads it, which is the arrangement where the
two drift.
Reversal cost: cheap.
hardline.yaml is the one file the overlay may not touch¶
Decided: an overlay that contains policy/hardline.yaml is a startup
error, not a silent no-op.
Why: the hardline set is defined as what configuration cannot disable, and an overlay is configuration. A silent no-op would let an operator believe they had changed something. Failing loudly at startup is the only behaviour consistent with the definition.
Reversal cost: cheap.
The composition root never runs migrations¶
Decided: startup asserts the schema revision matches and refuses to
start on a mismatch. make migrate stays a separate step.
Why: migrating on boot means N processes racing during a rolling deploy, and a process that failed to start having already changed the schema. Section 25 already treats migration as its own step. The cost is one more command in local setup, which the Makefile already has.
Reversal cost: cheap.
The deployment role is an entry-point argument, not a variable¶
Decided: runtime/worker.py passes role="worker" or
role="maintenance"; api/main.py and cli/main.py pass role="api".
The role is not in Settings.
Why: runtime-loop.md says all three deployment roles are the same
binary with a role flag, and a flag on the entry point is what makes a
process's role visible in the process table rather than in its
environment. It also keeps Settings to values that are genuinely
per-deployment; the role is per-process.
Reversal cost: cheap.
Four CLI options are added where the plan names none¶
Decided: --json, --session <id>, --role <role>, and --follow.
Why: Section 17 lists twelve commands and no options at all, which
reads as a command inventory rather than a complete grammar. Each of the
four is added because a command is otherwise unusable rather than merely
less convenient: without --session there is no way to continue a
conversation, without --role agent worker cannot start the
maintenance role the runtime spec requires, without --follow the events
command can only poll, and without --json nothing that calls the CLI
can parse it. If the plan's silence was deliberate minimalism rather than
omission, this is the entry to reverse.
Reversal cost: cheap, but the four are load-bearing in the milestone walkthroughs.
get, events, and cancel are reserved after agent run¶
Decided: agent run get <id> and agent run "get the weather" are
disambiguated by treating the three subcommand names as reserved words,
with -- as the escape: agent run -- get the weather.
Why: the plan gives both forms — agent run "Calculate 12 times 9"
and agent run get <run-id> — and never says how they are told apart. A
prompt is free text and can begin with any word. Reserving three words is
the smallest rule that makes the grammar decidable, and -- is the
convention every POSIX tool already uses for it.
Reversal cost: cheap. The alternative is a separate agent prompt
verb.
The secret scanner is specified rather than left to a tool choice¶
Decided: five rule families — provider key prefixes, PEM blocks, literal bearer values, DSNs with inline passwords, and secret-named assignments over twelve characters — plus a report that never prints what it matched and an allowlist whose entries require prose.
Why: dependency rule 12 and the Milestone 0 deliverable both name a
scanner and neither says what it looks for, which makes "the check
passes" unfalsifiable. Naming the families also fixes the thing a scanner
most often gets wrong, which is printing the secret it found into CI
logs. .env.example is scanned rather than exempted, because an example
file is exactly where a real key gets pasted by accident.
Reversal cost: cheap.
Transaction hygiene: the check is Milestone 0, the gate is Milestone 2¶
Decided: the two placements are not in conflict once check and gate are separated. The check ships in Milestone 0 with the other structural checks; the gate that asserts it passes over real repository code is a Milestone 2 acceptance criterion.
Why: the plan puts transaction hygiene in Milestone 0 and the runtime spec tags its gate Milestone 2. Milestone 0 has no database code to walk, so a Milestone 0 gate would pass vacuously; a Milestone 2 check would be added against code that already violates it. Shipping the check early and gating on it later is what both documents are each half-saying.
Reversal cost: cheap.
An event repository is Milestone 1; event storage is Milestone 2¶
Decided: Milestone 1's criterion that every state transition is
represented by an event is satisfied by an in-memory EventRepository
behind the same port. Milestone 2 adds the PostgreSQL adapter with
append-only guarantees, sequence allocation, and projections.
Why: the two milestones appear to contradict each other only if "event storage" and "event repository" are the same thing. One port with two adapters is the arrangement the whole plan is built on, and it is what makes Milestone 1's vertical slice real rather than mocked.
Reversal cost: cheap.
In-memory repositories are adapters, and there is no in-memory queue¶
Decided: five in-memory adapters in
adapters/persistence/memory.py, shipped as production code and run
against the same contract suites as their PostgreSQL counterparts, each
declaring which capability groups it satisfies against a checked-in
table. RunQueue gets no in-memory adapter.
Why: ADR-0001 defines replaceability as a port with a contract suite
attached; a double under tests/ would be a second, unverified
definition of what the port means. The RunQueue exception is the
inverse of the same argument — that port's entire content is FOR UPDATE
SKIP LOCKED and lease fencing, so an in-memory version would pass its
own tests while teaching the wrong lesson about what the port guarantees.
Reversal cost: cheap for the five; the RunQueue omission is what
keeps Milestone 1 from appearing to exercise the worker path.
A Composition exposes application services and nothing else¶
Decided: no adapter, repository, session factory, or engine is
reachable from the object build returns.
Why: ADR-0023 reserves RunRepository.transition to
runtime/executor.py, and the way that reservation survives a second
entry point is to make the repository unreachable from the entry points
rather than to rely on everyone remembering. Narrowing the return type is
the mechanism; the static check that no module outside bootstrap.py
instantiates an adapter is its enforcement.
Reversal cost: cheap now, expensive later.
runtime/engine.py is retired in favour of three files¶
Decided: the Section 4 tree's runtime/engine.py becomes loop.py,
executor.py, and supervisor.py, and the tree is annotated rather than
redrawn.
Why: ADR-0023 already split the loop from the executor and made the
split structurally enforced — only runtime/executor.py may call
RunRepository.transition — which a single engine.py cannot express.
supervisor.py is the heartbeat and deadline task the same document
requires. Keeping the old name would leave a gate pointing at a file that
does not exist.
Reversal cost: cheap.
ModelProvider was declared twice with incompatible shapes¶
Decided: model-gateway.md holds the canonical port. Section 7's
declaration is annotated as superseded and left in place, matching the
treatment already given to RunRepository.claim_next.
Why: the two declarations differ in the attribute name
(provider_name versus name), in the stream signature (the gateway
passes ResolvedModel and ModelAttempt), in the presence of close,
and in whether capabilities sits on the adapter at all — and neither
document said which one an implementer should write. The gateway version
is the one the rest of that spec, the router, and the retry ownership
split all assume.
Reversal cost: cheap.
Development toolchain (ADR-0025)¶
make check excludes the integration suite¶
Decided: make check is lint typecheck test-fast, where
test-fast is everything that needs neither a database nor a provider
credential. The integration suite has its own target and its own CI job.
Why: Section 21 requires both make test and make check, and
Section 24 makes "make check succeeds" a definition-of-done item. If
check contained the whole suite it would need Docker, and a fresh
checkout with no daemon running could not satisfy a definition of done.
The alternative — dropping the integration job from CI — would make the
harness's four-job shape decorative.
Reversal cost: cheap. One line in the Makefile.
Six Makefile targets were added to Section 21's eight¶
Decided: test-static, test-contract, test-fast,
test-integration, test-live, and docs.
Why: the governing rule is that CI runs no command the Makefile does
not define, and there are four CI jobs plus a documentation build. Each
addition exists because a job invokes it. Without them the workflow file
inlines pytest selectors, which is the exact drift that lets make check
and CI stop agreeing about whether the build is green.
Reversal cost: cheap, but reversing it reintroduces the drift.
Test selection is by pytest marker, not by directory¶
Decided: each of the six test directories is assigned a marker, and
static, integration, and live are the three that select. The
contract selector is a negation, so an unmarked test runs in job 2.
Why: the question a CI job asks is "can this run without Docker", not "which folder is this in", and three of the six directories need a database. Making the negation the default means a new test with no marker is visible rather than silently unrun.
Reversal cost: cheap.
PostgreSQL is pinned to 16-alpine¶
Decided: postgres:16-alpine, one service, named volume, healthcheck
that make db-up polls.
Why: the persistence layer depends on FOR UPDATE SKIP LOCKED
semantics, and a major-version change should be a reviewed change rather
than whatever latest resolved to that morning. Alpine is the smaller
image; the open question below records that its locale and collation
behaviour differs from the Debian-based tag, which is the surface a
sort-order-dependent query would hit.
Cost: none today.
Reversal cost: cheap, but a version change is a migration test.
The compose credentials are agent/agent and the scanner scans them¶
Decided: they live in .env.example, and they pass the secret
scanner by an allowlist entry carrying the prose reason "local compose
default, not reachable from outside the host network".
Why: ADR-0024 already decided the scanner scans .env.example
rather than exempting it. A scanner that skips the one file every
contributor copies is not a scanner, and an allowlist entry that has to
state a reason is a reviewed fact.
Reversal cost: cheap.
CI runs one Python version, not a matrix¶
Decided: 3.12 only.
Why: the project pins requires-python >=3.12 and runs one
deployment. A matrix tests a configuration nothing runs, at roughly
double the CI minutes. This is recorded as an open question in the spec
because it sets an expectation about what "supported" means.
Reversal cost: cheap. A matrix is three lines of YAML.
The live job never runs on a pull request¶
Decided: schedule and manual dispatch only. The job is defined at Milestone 0 even though no live adapter exists until Milestone 3.
Why: live tests cost money per run and need a credential a fork's pull request cannot hold. Defining the job early keeps the workflow file complete and costs one skipped job per night.
Cost: external contributors never see live results on their branch.
Reversal cost: cheap.
Egress is blocked at Milestone 0 by a pytest fixture¶
Decided: an autouse session fixture patches socket.socket to raise
on connect for the static and contract markers, exempting Unix
sockets and loopback for integration, and lifted by live.
Why: the harness's gate 7 requires the deterministic suite to run without an API key, and observes that this is usually implemented as "we did not configure one" — true until a fixture falls through to a real client. The requirement was previously attributed to Milestone 0 only by this document, which is not authoritative. A conftest fixture is the smallest thing that makes the claim testable: no firewall, no container network policy, no runner configuration.
Reversal cost: cheap.
"Initial ADRs" means carrying the accepted set forward¶
Decided: Milestone 0 copies docs/adr/ into the agent repository in
full, keeps the numbering and the index, and authors nothing new.
Numbering continues from the highest number carried over.
Why: the phrase had three defensible readings — the six filenames in the Section 4 tree, the eleven a note defers to their milestones, or the twenty-five that now exist. Carrying the set forward satisfies all three at once. Authoring a fresh ADR for a decision this corpus already recorded would create a second record that is edited independently of the first, leaving a reader unable to tell which one the code follows.
Reversal cost: cheap.
docs/security.md is placed at Milestone 0¶
Decided: the file Section 22 requires exists from Milestone 0.
Why: no milestone claims it. Section 24's definition of done requires "Security implications are documented" for every milestone, and this is the file that requirement writes to, so it must exist from the first milestone with security implications. Milestone 0 ships two security controls — the secret scanner and the egress block — so it is that milestone. This adds no deliverable to the Milestone 0 implement list; it identifies where an existing definition-of-done item lands.
Reversal cost: cheap.
docs-manifest.yaml was left at four sources¶
Decided: the single-file HTML publication continues to carry the index, the current milestone, the engineering plan, and the changelog. The MkDocs site remains the complete corpus.
Why: widening it correctly requires per-document anchor prefixing in
scripts/build_docs.py first. Thirty-seven documents share heading names
— "Decisions", "Context", "Open questions for review" — and the anchor
generator resolves duplicates to the first occurrence, so a naive
widening produces a publication whose cross-references silently point at
the wrong document. Doing it badly is worse than not doing it, and the
plan now carries roughly thirty cross-reference paragraphs that a reader
of the combined file cannot follow either way.
Cost: a reader handed the single file must go to the site for any specification or ADR.
Reversal cost: cheap once the anchor work is done; the manifest is generated-output tooling and no canonical source changes.
The structured-logging design is split across two specifications¶
Decided: the configuration shape, renderers, context variables, and
redaction processor are specified in development-toolchain.md; the call
site that runs the bootstrap is phase 1 of the composition root in
bootstrap-and-composition.md.
Why: logging is a Milestone 0 toolchain deliverable and the composition root is where every startup-ordered thing is sequenced. Both statements are true, and duplicating either would create a second place to edit. It is recorded as an open question because it is the one file in the corpus described by two specifications.
Reversal cost: cheap.
Builtin tools (ADR-0026)¶
Section 8.1's seven names are read as a convention, not a roster¶
Decided: the builtin roster is the union of Section 8.1's namespaced
names and Section 8.2's specified tools — eight tools, including
demo.external_write, which 8.1 omits, and artifact.export, which 8.2
omits.
Why: read as two rosters they contradict, and nothing in the plan
says which wins. tool-system.md's domain partition table already
reserves demo as a builtin domain registered at build time, which a
document treating 8.1 as complete would not have done. The 8.1 fence
follows the words "Use namespaced names:", which is a convention
illustrated by example.
Reversal cost: cheap. Removing a tool from the roster is deleting a row from the classification table.
artifact.export is placed at Milestone 6¶
Decided: Milestone 6, with the control tools and the programmatic bridge.
Why: the plan assigns it no milestone at all. Milestone 4 was
rejected because artifact export is not a workspace operation and would
put the model in front of the artifact store before that store's
retention and cross-tenant rules have been exercised. Milestone 5 was
rejected because pairing it with sandbox.run_command invites the two
designs to merge, and exporting a file is not a property of having run a
command. Milestone 6 is the first point at which the model rather than
the executor decides what leaves the run.
Reversal cost: cheap while it has no design; the classification is milestone-independent.
math.calculate gets a hand-written parser, not an ast allowlist¶
Decided: a hand-written tokenizer and precedence-climbing parser, roughly two hundred lines, no dependency.
Why: Section 8.2 forbids unrestricted eval and says nothing about
what to use instead. The conventional answer — ast.parse in eval mode
plus a node-type allowlist — was rejected on three grounds: an allowlist
is a subtraction from a grammar that grows with every Python release,
Python's parse semantics are not the ones the tool wants (/ is float
division, -7 // 2 floors while Decimal truncates), and ast.parse
can exhaust the C stack on deeply nested input before any allowlist
runs. A closed grammar that a test can enumerate is not available any
other way.
Cost: about two hundred lines to write and maintain, against thirty for the allowlist.
Reversal cost: moderate. The grammar is checked in and the property test asserts against it, so replacing the front end means replacing the test's premise.
The numeric type is decimal.Decimal at fifty significant digits¶
Decided: Decimal, prec = 50, ROUND_HALF_EVEN, Emax and
Emin at ten thousand, with InvalidOperation, DivisionByZero,
Overflow, and Underflow trapped.
Why: the most commonly reported class of "the model got the
arithmetic wrong" is a tool returning 0.30000000000000004 for
0.1 + 0.2. A calculator that reproduces binary floating-point surprise
has given up its only advantage over the model's own token-level
arithmetic. Fifty digits is past anything a calculator tool is asked and
bounds the cost of exp and ln. Putting the magnitude bound in the
context rather than the evaluator means no call site can forget it.
Reversal cost: raising the precision is a one-line change nobody would notice; lowering it is not, once results have been rendered.
// and % floor rather than truncate¶
Decided: Python's int semantics — floor(a / b) and a remainder
whose sign follows the divisor — implemented explicitly rather than by
calling Decimal's operators.
Why: Decimal truncates toward zero, so Decimal(-7) // Decimal(2)
is -3 where -7 // 2 is -4. The caller is a language model whose
prior for both operators is Python's, and whose reasoning about a
modulus is almost always a positive-residue argument. Being wrong in the
other direction produces a correct argument built on an unexpected -1,
which is confidently wrong with no failure anywhere.
Cost: someone reading the implementation has to notice the operators
are not Decimal's.
Reversal cost: cheap now — one function, one differential test, one
sentence of the description — and expensive once a model has learned
the tool's behaviour.
There is no trigonometry at Milestone 1¶
Decided: the function set is abs, ceil, floor, round,
sqrt, ln, log10, exp, min, and max, plus the constants pi
and e.
Why: not difficulty — a Taylor series over Decimal is short — but
that sin(90) has two defensible answers and a result has no field in
which to say which convention it used. A tool that silently reads
degrees as radians returns a plausible wrong number. Adding trigonometry
means adding a units argument or a second family of names, which is a
decision to make when something needs it.
Reversal cost: cheap, but it is a ToolSpec version bump, which
invalidates every cached prefix.
Builtin failure messages carry the supported set and never the input¶
Decided: the model-facing message for every reason code is static;
unknown_name carries the whole supported function and constant list;
the character offset of a syntax error goes to detail, which the
operator sees and the model does not.
Why: hard gate 4 requires a static message per reason_code, and
that table is one table for every tool including MCP tools whose failure
text is written by a third party. A table with one interpolating entry
has no invariant left. The mitigation is to make the reason codes
specific enough — syntax against arity against domain against
unknown_name — that the model can tell a typo from a misunderstanding
without being told where.
Cost: a model that mis-parenthesizes has to re-derive where, from an expression it wrote.
Reversal cost: cheap for this tool, expensive as a precedent.
Clock.now() is declared to return an aware UTC datetime¶
Decided: aware, tzinfo UTC, asserted in the Clock contract
suite.
Why: runtime-loop.md types it as datetime and says no more.
Every consumer so far compares two values from the same clock, so the
ambiguity was harmless; system.current_time converts between zones and
is the first consumer for which it is not. A naive datetime cannot be
converted without assuming the process's local zone, which would smuggle
ambient state through a port built to keep it out.
Reversal cost: none in practice — it is a clarification every existing consumer already satisfies.
system.current_time accepts IANA names only and defaults to UTC¶
Decided: resolved through zoneinfo.ZoneInfo; abbreviations like
EST, fixed offsets like +05:30, and the literal local are all
rejected; the default is UTC rather than the process's local zone;
tzdata becomes a declared runtime dependency.
Why: abbreviations are ambiguous — IST names three zones and CST
names four — so a tool that picks one has answered a different question.
Defaulting to the server's zone makes the answer depend on which host
ran it, which is the nondeterminism the Clock port exists to remove.
tzdata is declared because the failure without it is that every name
except UTC stops resolving in a slim image while every developer
machine passes.
Cost: one more runtime dependency in the image.
Reversal cost: accepting fixed offsets later is cheap; removing them once accepted is not. Recorded as an open question in the spec.
Hard gate 2 is restated over target_kind as well as source¶
Decided: no spec whose source is MCP or DEVICE, or whose
target_kind is sandbox or device, may declare output_trust above
EXTERNAL_UNTRUSTED.
Why: tool-system.md states the gate over source, which catches
MCP and device tools. sandbox.run_command is a builtin — source =
BUILTIN, target_kind = sandbox — so the gate as written does not
reach the one builtin that returns bytes produced by code we did not
write, which is exactly what the gate exists to stop being read as
trusted narration.
Reversal cost: none. It is a widening that fails nothing currently declared.
allow_parallel is false for every builtin that writes¶
Decided: true for the four read-only tools, false for the other four.
Why: it costs nothing at Milestone 1, where both tools are pure, and it removes an ordering-bug class that would first appear at Milestone 4 when three workspace tools can appear in one batch. Loosening it later is a measured decision; tightening it after a bug is a regression investigation.
Reversal cost: cheap, but it is a ToolSpec version bump.
Classification is settled for all eight tools four milestones early¶
Decided: every ToolSpec field that registration validation reads
or the 9.2 matrix is keyed by has a value now, including for
sandbox.run_command and artifact.export, whose behaviour is not
designed.
Why: hard gate 1 refuses to start without output_trust on every
registered spec, and no builtin in the corpus declared one. Settling
classification is also what lets Milestone 4's policy work run against
real registry rows. The alternative — specifying all eight completely
now — is false precision, because sandbox.run_command's design is the
sandbox design and would be rewritten after Section 28's mechanism
decision is exercised.
Reversal cost: cheap. A classification field is one table cell and one line of a spec.
Milestone map (ADR-0027)¶
One heading, one form, and one **M<n>.** suffix across ten specs¶
Decided: every declaring spec spells the section ## Hard gates,
declares each gate as a numbered item with a bolded lead, and ends each
item with a milestone token the docs check reads. Five headings were
renamed, four bullet lists became numbered lists, and seventy-five
tokens were added.
Why: registry rule 1's docs check is a Milestone 0 deliverable and it could not be written against the corpus. The section was spelled three ways, declared two ways, and nine of the ten specs stated no milestone at all. Teaching the check all three headings and both forms was rejected: it makes the check the place where the inconsistency is preserved rather than removed, and a parser with three branches is one nobody trusts to fail correctly.
Cost: ten files edited mechanically. No sentence stating a requirement changed, which was checked sentence by sentence rather than asserted.
Reversal cost: cheap but pointless. Reverting the form means rewriting the check to be worse.
Nine bullets carrying eleven metrics moved out of two gate lists¶
Decided: tracked metrics live in a sibling ## Tracked metrics
section. Five specs gained one. The two specs that interleaved gates
with metrics in a single list keep fourteen gates and lose nine
bullets.
Why: a count over an interleaved list is undefined until somebody reads every bullet and decides, which is exactly what rule 1's check cannot do. The separation was made on the specs' own words — "A hard gate", "The primary metric", "not a metric to improve" — rather than on my judgment about which items sounded gate-like.
Reversal cost: cheap. The bullets moved intact.
The harness's memory-formation count of seven becomes five¶
Decided: five gates, not seven. Four of that spec's eight bullets are the metrics the spec itself calls metrics.
Why: the harness's table was written when six specs existed and counted a bullet list nobody had yet had to parse. Correcting the count was unavoidable once the list was split, because the table is now asserted against the registry rather than maintained by hand.
Reversal cost: none. It is a derived number.
optional is added to the registry for exactly one gate¶
Decided: a bounded optional field, used by the model gateway's
live vendor smoke test, which may report skipped when its named
precondition — a credential — is absent.
Why: the registry forbids a skip, and this gate cannot run in a fork without vendor keys. The alternatives were deleting a gate that catches real provider drift or letting a skip pass silently, which is the thing the registry exists to prevent. Use is bounded to external credentials in writing.
Question for you: a second use is a design smell I would rather argue in review than legislate now. If you want the field forbidden outright, the gate becomes a scheduled job outside the gate set.
Reversal cost: moderate. Removing the field means finding another home for the gate.
Milestone 1 cancellation is a deadline plus a SIGINT handler¶
Decided: CancellationToken is buildable at Milestone 1 as a
lazily evaluated deadline plus a process signal handler, and
CancelReason splits by dependency — DEADLINE at Milestone 1,
FENCED at Milestone 2, REQUESTED by poll at Milestone 2 and by
endpoint at Milestone 5.
Why: the runtime loop assigns the token and three observation
points to Milestone 1 while every writer it names lives in a supervisor
task Milestone 2 builds. Deferring all cancellation to Milestone 5 —
Section 21's reading — leaves three observation points unreachable for
four milestones, and the code that must observe them is written at
Milestone 1 either way. A deadline and a signal need only Clock and
the process.
Question for you: this is the one placement where I overrode a milestone the plan states rather than one it leaves silent. It is recorded as an open question in the map for that reason.
Reversal cost: cheap. Deleting the two writers leaves the observation points dead but correct.
The import-boundary walk is owned by this plan, not by a spec¶
Decided: gate.structure.import_boundary has one owner, the
engineering plan's Milestone 0, and tool-system.md keeps its sentence
as a declared alias. Two other gates were declared twice and are owned
by event-log-and-persistence.md, with runtime-loop.md restating
them.
Why: a count check that does not know about a double declaration double-counts, and an implementer who does not know writes the same assertion twice under two names. The non-owning spec keeps its sentence because a reader of the tool system should still learn that the import boundary is checked; what changed is that the sentence now says it is the same gate. Ownership follows declaration, which put one registry entry outside the detailed-design specs.
Reversal cost: cheap. Ownership is one column.
The map declares seven gates of its own, raising the registry to 94¶
Decided: the milestone map's seven checks over the corpus are
registry entries in the harness area — token presence, map bijection,
alias arithmetic, anchor resolution, identifier grammar, a derived
census, and milestone ordering. Six are Milestone 0 and one is
Milestone 1.
Why: I found this by running the map's own gate 2 against the map:
seven gates were stated under its hard-gates heading and absent from
its census, its gate table, and the harness's kind table, so the
document failed its own bijection check. Registering them is the only
resolution that does not weaken a gate. They take the harness area
because the harness owns the registry and the docs check that reads it;
the map declares them because each is a check over the scheduling
record, which is what the map is.
Cost: the registry goes from eighty-seven entries to ninety-four and Milestone 0 goes from five gates to eleven. Every derived count in two documents was recomputed.
Reversal cost: moderate. Unregistering them reopens the bijection failure.
Tool-system build step 3 splits at the unique index¶
Decided: the idempotency port, its semantics, and its contract suite are Milestone 1; the unique index that makes deduplication correct under concurrency is Milestone 2. The in-memory adapter declares the gap against the capability table rather than simulating it.
Why: the tool system places persistence at step 3 and calls steps 1
through 5 Milestone 1, while the plan forbids PostgreSQL persistence
until the in-memory slice is complete. This is ADR-0024's
repository-versus-storage separation applied a second time, and its
reason for rejecting an in-memory RunQueue applies unchanged: a
simulation of a unique index passes its own test and teaches the wrong
lesson about what the port guarantees.
Reversal cost: cheap. The port does not change; one gate moves.
Context-engine gate 1 moves from Milestone 7 to Milestone 1¶
Decided: the deterministic-assembly gate and build-sequence step 1 are Milestone 1. The other four gates stay at Milestone 7.
Why: the spec states Milestone 7 once for the whole section, and
ADR-0024 decision 10 had already scheduled that gate's subject — the
minimal context builder and prefix_sha256 — into the vertical slice.
This is the evidence that a section-level milestone is not good enough,
and it is why the form is now per-gate.
Reversal cost: none. The gate follows the code that was already scheduled.
Milestones 6 and 8 add no gates, reported rather than fixed¶
Decided: report the shape. No gates were invented to fill the two empty columns.
Why: every invariant those two milestones strengthen is already registered against an earlier milestone, so the columns are empty for a defensible reason rather than an oversight. Inventing a gate to fill a column produces a check written to exist rather than to fail.
Question for you: this is worth a look when you are back. If isolated execution and skills genuinely warrant new invariants, they should be stated in those specs and land in the registry from there, not be back-filled from the map.
Reversal cost: none. Adding a gate later is the normal path.
Thirty-eight of ninety-four gates are green before Milestone 2¶
Decided: reported as a finding, not acted on.
Why: it was not knowable before the gates were counted, and it is the strongest argument in the corpus for building the in-memory tier as real adapters rather than as test doubles. Eleven of the thirty-eight hold against a repository with no agent in it.
Reversal cost: not applicable; it is an observation.
Readiness review¶
Coverage is judged against the whole corpus, not the specs alone¶
Decided: a mechanism designed in the engineering plan and nowhere else counts as covered, and is labelled plan-only so the distinction stays visible.
Why: Section 16 specifies nine endpoints with methods, paths,
headers, request and response bodies, the SSE frame format, the
reconnect rule, and the error envelope. Scoring that as absent because
no file under docs/plan/ expands it would produce a Milestone 5
verdict that is false in the way that matters, since an implementer
reading Section 16 can build most of that milestone. The distinction is
still reported, because every other plan section was expanded exactly
once and every expansion found a real conflict.
Reversal cost: none. It changes a label, not a finding.
An explicit deferral scores as partial rather than absent¶
Decided: where a document names what it does not design, the undesigned thing is partial.
Why: builtin-tools.md has a section titled "The six tools this
document does not design" that assigns each a milestone and lists what
it owes, and tool-system.md states that device tools are a reserved
seam rather than a design. A hole somebody has measured is a different
object from one nobody has looked at, and collapsing the two would make
the review least useful exactly where the corpus is most self-aware.
Cost: it makes the review's counts look better than a naive scan would. The compensation is that each deferral is named individually with what it still owes, so nothing hides behind the label.
Reversal cost: none.
The readiness review resolves no conflict¶
Decided: it reports four milestone conflicts and defers each to the document that owns the subject.
Why: the milestone map owns scheduling and the specs own their requirements. A readiness review that also decided things becomes a document the other documents must be reconciled against, which is the problem the map was written to solve.
Reversal cost: none.
The API specification is named as the next document to write¶
Decided: the review recommends writing an API specification before coding reaches Milestone 5, rather than building that milestone from Section 16 directly.
Why: Milestone 5 registers one gate, the fewest of any milestone
that adds work, and no detailed-design spec covers the API layer. Six
items inside Section 16 are visibly unsettled: the error envelope has
one example and no code list, request IDs are a bullet, whether the
HTTP Idempotency-Key and the Milestone 1 idempotency port are one
mechanism is undecided, the SSE consumer side is one sentence
forwarding to Section 16, authentication is designed only at its
refusal, and nothing turns an HTTP cancel into an observation by a
worker in another process.
Question for you: this is the largest remaining piece of design work and I have recommended rather than started it, because it is a document rather than a reconciliation and you may want the API surface to be yours. The counter-argument is real: Section 16 is more detailed than the sections that turned out to hide contradictions, HTTP has stronger conventions than composition roots do, and the six items are individually small.
Note: since written. It is http-api-and-streaming.md and ADR-0028, and the expansion found nine contradictions, which settles the argument above on evidence rather than on the prediction. The decisions taken while writing it are recorded under HTTP API and streaming below.
Reversal cost: low now, high after clients exist.
The sandbox specification is named as the second document¶
Decided: reported as the highest-consequence gap, and flagged as possibly belonging before Milestone 5 rather than after.
Why: Milestone 6 registers zero gates, eight of its twelve implement
bullets have no design outside the plan, and tool-system.md:977
already constrains MCP server URLs by "the egress allowlist the sandbox
spec establishes" — a specification that does not exist. It is the only
undesigned area whose failure mode is a trust boundary rather than a
missing feature.
Question for you: milestone order says the sandbox spec follows the API spec. I have not reordered them, but writing it first costs nothing except the order in which two documents get written, and one document already depends on it.
Reversal cost: none; it is an ordering question between two unwritten documents.
Knowledge retrieval is reported as possibly its own milestone¶
Decided: reported, not acted on.
Why: knowledge is half of Milestone 9's title and has no design anywhere — no ingestion, chunking, indexing, or scoping — while the memory half has two complete specifications and fourteen gates.
Question for you: splitting knowledge out would make Milestone 9 shippable against what is actually designed. I did not split it, because renumbering milestones touches every document in the corpus and that is your call rather than mine.
Reversal cost: high once made, which is why it is a question.
HTTP API and streaming (ADR-0028)¶
The wire error vocabulary is the existing taxonomy, snake-cased¶
Decided: the code list is Section 13's twenty-three error classes
and the runtime loop's eight, mechanically snake-cased, plus four
API-specific codes for conditions with no domain class —
malformed_request, unsupported_media_type, payload_too_large,
rate_limited.
Why: Section 16's only worked example is tool_validation_error,
which is ToolValidationError snake-cased. The convention was already
chosen; the document applies it rather than picking a second one. A
hand-maintained mapping between an internal taxonomy and a separate
client vocabulary is a thing that drifts, and the drift is silent.
Alternative: a small vocabulary designed for clients, which is what most APIs have and which reads better. It costs a mapping table somebody owns.
Reversal cost: moderate. Renaming a code after a client depends on it is a breaking change, and the codes are the part of an API that is hardest to change.
Four error classes deliberately never reach a client¶
Decided: WorkerFenced and EmptyModelTurn are internal, two
further classes have no client-actionable meaning, and any class not
in the map resolves to internal_error and 500.
Why: a fenced worker is not a run failure and an empty model turn is retried internally, so surfacing either would describe an event the client cannot act on and did not cause. The catch-all exists so that adding a class to the taxonomy cannot leak a class name onto the wire.
Cost: a genuinely new client-facing condition will arrive as
internal_error until somebody maps it. The gate that every returned
code is in the vocabulary catches the leak, not the omission.
Reversal cost: cheap.
Scopes are exact-match strings over a closed dotted vocabulary¶
Decided: eight scopes, matched by string equality. No wildcards,
no prefixes, no hierarchy in which run.write implies run.read.
Roles are bundles resolved at authentication and the API never checks
a role.
Why: a hierarchy needs a grammar, a grammar has an evaluation
order, and an evaluation order can be subtly wrong in the direction of
granting access. Exact match cannot be subtly wrong.
engineering-plan.md:459 names scopes and no document had stated
their grammar.
Cost: callers list every scope they need, and adding a route may mean adding a scope to existing principals.
Reversal cost: cheap while the vocabulary is closed; adding a hierarchy later is compatible with exact-match tokens already issued.
A resource in another tenant is 404, never 403¶
Decided: generalized from the rule the policy spec already fixes for approvals. Scope is checked before tenancy.
Why: 403 is the more informative answer and that is the problem — it confirms the resource exists, which turns identifier enumeration into a working attack. Checking scope first means a principal with no scope cannot watch 403 become 404 and learn the same thing.
Cost: a caller with a genuine permission problem sees the same response as a caller with a typo. The structured log distinguishes them; the wire does not.
Reversal cost: cheap.
SessionStatus is declared uppercase, against Section 16's sample¶
Decided: ACTIVE and CLOSED, matching RunStatus and the
guarded updates in the DDL. Section 16's sample shows "active" and
Section 16 is not edited.
Why: the type is referenced in Section 5 and declared nowhere, so something had to declare it. Every other status in the corpus is uppercase. Lowercase session status beside uppercase run status on one wire is what a client library encodes as two enums and a comment.
Question for you: editing Section 16's one-line sample would settle this in a character, and the conversion rules forbid editing the plan's requirements during conversion. This is the one place the new document and the plan disagree on a literal value.
Reversal cost: cheap now, expensive after a client parses it.
The two things called "idempotency key" are two mechanisms¶
Decided: the HTTP Idempotency-Key header on message submission
and tool_invocations.idempotency_key are unrelated. Different
scopes, different tables, different milestones.
Why: the milestone map schedules the tool key as a Milestone 1
port on ToolInvocationRepository, which is a tool-call concern,
while the idempotency_keys table is Milestone 2 and carries a
request_hash for an HTTP body. Unifying them would put a table at a
milestone whose DDL does not exist. The readiness review left this
undecided and deferred it here.
Cost: two mechanisms with one name is a naming problem that will be rediscovered. The document names both explicitly for that reason.
Reversal cost: cheap.
A repeated submission with a different body is a conflict¶
Decided: a repeat of an Idempotency-Key whose request_hash
matches returns the original run with 200; a repeat whose hash differs
returns 409.
Why: returning the original run for a different body would answer a question the client did not ask, silently. A client that reuses a key by accident has a bug, and 409 makes the bug loud at the point it happens rather than at the point its consequences are noticed.
Reversal cost: cheap.
A second message to a WAITING_FOR_USER run is routed, not rejected¶
Decided: routed to input delivery, returning 202. Every other non-terminal run status returns 409.
Why: Section 27.3 permits either and requires one to be configured. Routing is what a user answering a question expects, and rejecting makes every client re-implement a rule the server already has the state to apply.
Question for you: the counter-argument is that rejecting is more explicit and a client that meant to start a new run gets told so. The plan calls this a configured policy, so both remain reachable; this sets the default.
Reversal cost: cheap. It is a configuration default.
Transient stream frames carry no id field¶
Decided: token deltas and other non-persisted frames are sent
without id. Only frames backed by a persisted event carry one.
Why: the EventSource specification advances a client's
last-event-ID only on a frame that carries id. A synthetic id on a
transient frame therefore sets the client's resume point to a value
the server cannot resolve, and every subsequent reconnect is wrong.
This is the most tempting wrong decision in the document, because
uniform framing looks tidier.
Reversal cost: cheap to state, expensive to discover late — the failure is silent and appears only after a reconnect.
Replay subscribes before it reads¶
Decided: LISTEN first, buffer arrivals, read the persisted
prefix, note the high-water mark, drain the buffer discarding at or
below it, then go live.
Why: the obvious order is to read the log and then subscribe, and it drops every event committed in the window between the two. The window is small and it is not empty. Subscribe-before-read is what makes the handoff gapless; discarding by sequence is what makes it duplicate-free. Eval case 22 tests exactly this.
Reversal cost: cheap now. It is an ordering inside one function.
Overflow closes the stream with a resumable marker¶
Decided: a client too slow to drain gets its stream closed with a marker it can resume from, rather than an unbounded buffer or a silent drop.
Why: unbounded buffering makes one slow client a server-wide memory problem, and silent dropping breaks the only guarantee the stream makes. Closing is recoverable because the durable log is the source of truth and the client reconnects against it.
Reversal cost: cheap.
Artifact content is always served as an attachment¶
Decided: Content-Disposition: attachment for every media type,
with no inline exceptions.
Why: an artifact is model-influenced content served from the API origin. Serving it inline is stored cross-site scripting. A list of media types deemed safe is a thing that grows by argument, and the argument happens in a pull request rather than in a threat model.
Cost: a browser client that wants to display an image fetches it and creates an object URL. That is a few lines in the client.
Reversal cost: cheap to relax, and relaxing it is the direction that carries the risk.
Health is the only unauthenticated surface, and it says little¶
Decided: /health/live and /health/ready need no credential,
and their bodies carry no version, host, dependency name, or count.
Why: an unauthenticated endpoint that reports the database is unreachable, or which version is deployed, is reconnaissance. Being unauthenticated is safe only if there is nothing there.
Cost: an operator debugging readiness reads logs rather than the probe body.
Reversal cost: cheap.
The four application service signatures are fixed here¶
Decided: SessionService, RunService, ApprovalService, and
ArtifactService get their method signatures in this document, each
method taking Principal first and returning view types rather than
rows.
Why: bootstrap-and-composition.md names all four as what build
returns and gives none of them a signature, and the readiness review
found ApprovalService in that state. Section 17 makes the CLI a
second caller, and two callers discovering a signature independently
is how the CLI ends up importing a web framework.
Reversal cost: cheap. Nothing implements them yet.
One route is added: GET /v1/sessions/{session_id}¶
Decided: added. Thirteen routes rather than twelve.
Why: a client reconnecting with only a session identifier otherwise cannot learn the session's status or find its active run. It exposes no capability a client holding its own records lacked.
Question for you: GET /v1/runs, a list of runs in a session, is
the obvious next route and was not added, because nothing in 0.1 needs
it and active_run_id covers reconnect. Say if you want it.
Reversal cost: cheap.
413 and 429 get response shapes now and mechanisms later¶
Decided: both codes are in the vocabulary with defined bodies; neither has a mechanism in 0.1. Section 22 owns rate limiting.
Why: rate-limit numbers should come from operational data that does not exist. Fixing the shape means a client written against 0.1 already handles the day the mechanism arrives, which costs nothing and avoids inventing a mechanism blind.
Reversal cost: cheap.
One authentication token, with no rotation without a restart¶
Decided: auth_token stays the single SecretStr that
bootstrap-and-composition.md declares. Comparison is
constant-time. Dev mode is bound to loopback.
Why: changing a declared Settings field is a change to another
document's design, which this assignment does not do unilaterally.
Question for you: should auth_token become a list, so a token
can be rotated by adding the new one, deploying, and removing the old?
As it stands the only rotation procedure drops in-flight requests.
This is the API's most operationally awkward decision.
Reversal cost: cheap now — one field, one comparison loop.
The stream is per run, and heartbeats are every fifteen seconds¶
Decided: the event stream endpoint is per run. Fifteen seconds between heartbeats when no event is flowing.
Why: ADR-0010 fixed stream ids as the session sequence, so a run's stream is deliberately non-contiguous and clients must not infer a gap from it — which the document states as a rule because it looks like a bug. Fifteen seconds sits under common proxy idle timeouts and is otherwise arbitrary.
Question for you: a session-scoped stream route is nearly free, since the ids are already per session, and no client in 0.1 wants one. Worth adding now or later?
Reversal cost: cheap.
The policy rule behind an approval is not exposed to clients¶
Decided: not exposed. The approval view carries what the policy spec already permits and no rule identifier.
Why: the policy spec withholds it deliberately, and this document does not overturn another document's security decision as a side-effect of writing a response body.
Question for you: an operator debugging a denial currently has only the structured log. An operator-scoped field would fix that without widening the client surface. That is a policy decision rather than an API one, which is why it is asked rather than taken.
Reversal cost: cheap to add, expensive to remove.
Event retention is left unbounded and is flagged¶
Decided: no retention rule is introduced.
Why: the corpus bounds checkpoints, memory, and artifacts, and
never bounds events. Introducing a retention rule here would be
inventing a durability policy inside an API document.
Question for you: replay depends on the log being complete for a session's lifetime, so any future retention rule interacts directly with the reconnect guarantee this document makes. It should be decided where durability is owned, and it is currently decided nowhere.
Reversal cost: the decision is cheap; the migration that implements it will not be.
The readiness review is narrowed rather than left standing¶
Decided: the review's claim that the cross-process cancel path
"is not specified anywhere" is corrected in place. The worker half was
specified all along in runtime-loop.md; only the API half was
missing, and it was one column write.
Why: the review is a live statement of what the corpus covers, and an overstatement in it would send an implementer looking for a design that already exists.
Reversal cost: none.
Historical records are not rewritten when the gate count changes¶
Decided: ADR files, this file's existing entries, and the changelog keep the numbers that were true when they were written. The milestone map, the harness gate table, the engineering plan's routing paragraphs, and the readiness review are updated, because they are statements of current fact.
Why: an ADR is a record of a decision at a point in time and rewriting its arithmetic destroys that. ADR-0028 states the new total instead, which is where a reader looking for the change will be.
Reversal cost: none.
The gate registry (no ADR)¶
The secret scanner is a gate, and it is registered¶
Decided: gate.structure.no_committed_secrets, structural,
Milestone 0, owned by the engineering plan. It was specified in full in
bootstrap-and-composition.md, carried a gate identifier there, ran in
make check, and failed the build — and it was in no registry table.
Why: the corpus's own definition of a hard gate is that it fails
the build, and every other check named in the engineering plan's
Milestone 0 acceptance criteria is a registry entry. Leaving this one
out is exactly the drift gate.harness.registry_complete exists to
catch, arriving before there is any code to catch it in.
Cost: the registry goes from one hundred and four entries to one hundred and five and every cumulative figure from Milestone 0 onward rises by one, one day after ADR-0028 fixed the totals at one hundred and four.
Alternative: demote it — change Gate id to check id in
bootstrap-and-composition.md and correct the map's three gates to
two. Cheaper by about a dozen edits and defensible, since the four
AST rules in the same section are explicitly not gates. Rejected
because those four are assertions about module structure that hold
vacuously in an empty repository, and this one is a scan of committed
text that can fail on the first commit.
Reversal cost: low. One registry row and the arithmetic that counts it.
The area is structure, not security¶
Decided: the identifier's area changes from security to
structure. No twelfth area is added.
Why: security appears in exactly one identifier in the whole
corpus and in no grammar. structure is defined as the structural
statements about the repository that no single subject spec owns,
which describes a repository-wide scan precisely. The map already
claimed three structure gates while only two existed, so this makes a
sentence true rather than making a vocabulary bigger.
Note: if security-specific gates accumulate later — egress
enforcement and sandbox escape are the obvious candidates, and the
sandbox specification is the next document — a security area may
earn its place. One gate does not.
Question for you: the sandbox specification will register gates for
egress and isolation. Should those take a new security area, or stay
in structure and tool?
Reversal cost: low, while nothing consumes the identifier.
The engineering plan owns it, not the spec that specifies it¶
Decided: the spec field points at the engineering plan's
Milestone 0 acceptance criteria, the same place
gate.structure.import_boundary points. bootstrap-and-composition.md
continues to declare no gates.
Why: the plan already names the secret scanner in that list, and
the map already explains that a plan-owned entry is what happens when a
requirement belongs to no subject spec. Giving
bootstrap-and-composition.md a ## Hard gates section for one gate
would make the map's two specs declare no gates at all false and
change the declaration-form arithmetic, to record the same fact.
Reversal cost: low.
Sandbox isolation and artifacts (ADR-0029)¶
The gates take a new sandbox area, not security and not both¶
Decided: the thirteen gates the sandbox specification declares take
a new twelfth area, sandbox. The registry's area vocabulary becomes
structure, runtime, tool, builtin, model, policy, event, context,
memory, harness, api, sandbox.
Why: this answers the question left open under the gate registry
above — "the sandbox specification will register gates for egress and
isolation. Should those take a new security area, or stay in
structure and tool?" — and the answer is a third option that was
not on the list when the question was asked.
security was rejected on the same ground the single secret-scanner
gate was: an area names a subject some document owns, not a
cross-cutting property. Egress, isolation, limits, and artifacts are
not the only security-relevant gates in the corpus — cross-tenant
denial, the injection corpus, and credential scrubbing all live
elsewhere — so a security area would either pull those out of the
areas that own them or be a security area that does not contain the
security gates.
Splitting them across structure and tool was rejected because it is
false to the registry's own rule. One specification declares all
thirteen, and memory is the precedent: the two memory specifications
took one memory area between them, fourteen gates, rather than
distributing them into event and context.
Cost: the area list grows by one, and every document that states the area count changes. The map, the harness gate table, and this file are the three.
Reversal cost: low, while nothing consumes the identifiers. Renaming an area is one column plus three sentences of prose.
The workspace is a cache held for a lease, not state held for a run¶
Decided: a run's workspace exists for a worker's lease on that run,
not for the run's logical lifetime. It is created lazily at the first
sandbox-targeted call, held across steps and across an approval hold
shorter than approval_hold_seconds, and destroyed with the sandbox
when the lease ends. A run that resumes gets a fresh, empty workspace,
and anything that must survive is an artifact.
Why: the plan requires a workspace and never says how long one lives, and the two candidate answers are not close. A durable per-run workspace needs shared storage between execution hosts, which puts back the cross-tenant blast radius that one sandbox per run removes; it makes the workspace a second piece of state whose consistency with the event log nobody owns; and it turns crash-resume into recovery. With the workspace as a cache, resume needs no recovery at all — the sandbox is gone, so there is nothing to reconcile.
Cost: a tool that writes a file and expects to find it after a long
approval hold will not find it. The specification makes that explicit
rather than incidental, and artifact.export is the way to keep
something.
Question for you: the hold threshold is approval_hold_seconds,
which is 300. A workspace survives a five-minute approval and is
dropped past it. If real approvals routinely take longer than that, the
number is what to revisit, not the rule.
Reversal cost: high once tools are written against it. The rule
shapes what sandbox.run_command is allowed to promise.
sandbox.run_command is Milestone 6; builtin-tools.md was wrong¶
Decided: the tool ships at Milestone 6. The five places in
builtin-tools.md that put it at Milestone 5 are corrected — the
roster, the classification argument, and the two prose passages that
called Milestone 5 the sandbox milestone.
Why: Section 8.2 says the tool arrives "only after the sandbox
milestone", and Section 21 names Milestone 5 "HTTP API and SSE" and
Milestone 6 "Isolated execution and artifacts". builtin-tools.md
read "the sandbox milestone" as 5 and said so twice. That is a
transcription error against the plan's own milestone list rather than a
scheduling disagreement, so correcting the spec reverses nobody's
decision.
The readiness review reported this conflict and deferred it here, because the real question was whether the tool could ship early against the development sandbox mechanism and gain container backing later. It cannot: that mechanism refuses to start when the environment is production, and a tool that only works in development is not a milestone deliverable.
Note: the argument builtin-tools.md was making survives the
correction and is kept. artifact.export and sandbox.run_command both
touch a workspace and must not be merged into one tool — one is
IDEMPOTENT and runs in process, the other is neither.
Reversal cost: low. Nothing is built.
The red-team escape test becomes case 26, and nothing is renumbered¶
Decided: the container escape Section 28.7 demands becomes harness
case 26, a security case at Milestone 6 backed by
gate.sandbox.escape_denied. The twenty-five existing cases keep their
numbers and their text, and the heading that names them stays.
Why: the harness promises that Section 20's twenty-five cases stay
twenty-five, and gate.harness.anchor_resolves asserts that a case
number resolves to a stable anchor. Renumbering to slot a security case
in beside case 19 would break both to gain tidiness. Appending costs
nothing, and the rule it sets down — a case added later takes the next
integer and no case is ever renumbered — is worth having in writing
before there are a hundred cases.
Note: the case is skipped rather than passed when the configured
mechanism is fake, and the skip is a failure at Milestone 6. A fake
that reports no escape proves nothing, and a security case that passes
because it did not run is worse than no case.
Reversal cost: low.
Artifacts expire after thirty days by default¶
Decided: expires_at is written at creation as thirty days out,
and a sweeper deletes what has passed. Deletion is by expires_at
alone, never by reference counting.
Why: the plan requires a retention rule and gives no number. Thirty days is long enough that an artifact outlives the run that produced it and any review of that run, and short enough that a tenant's store does not grow without bound from the first day. Reference counting was rejected because an artifact referenced by an event that is itself kept forever is never collectable, which is a retention rule that does not retain.
Question for you: this is the one default in the document that silently deletes something a user might expect to keep, and thirty days is a guess at what is really a product decision. If artifacts are meant to be durable — the output of a long task somebody comes back to a month later — the number is wrong, and the right answer is probably per-tenant configuration with a floor.
Reversal cost: low before Milestone 6 and low after. It is a column default and a sweep interval.
fake is a fourth sandbox mechanism and a real adapter¶
Decided: SandboxMechanism gains a fourth value, fake, which
runs commands in a temporary directory with no isolation at all. It is
a production adapter in the same sense the in-memory repositories are:
it runs the ExecutionEnvironment contract suite unchanged, and the
composition root refuses it when the environment is production, by the
same startup check that refuses docker.
Why: the contract suite has to run in CI with no container runtime available, and the alternative is a test double that lives in the test tree and drifts from the port it doubles. The in-memory tier at Milestone 1 is the precedent the corpus already set.
Note: two of the thirteen gates exist because this value exists —
the startup refusal at Milestone 1, and the rule that the security
cases run against a real runtime and never against fake.
Reversal cost: low.
Section 28 is not edited, and the specification is subordinate to it¶
Decided: sandbox-isolation.md expands Sections 18 and 28 without
changing either. Where it is more specific than the plan — the
allowlist grammar, the three-tier environment, the workspace lifetime —
it is filling in what those sections left open, not deciding against
them.
Why: the conversion rules forbid materially rewriting the
engineering requirements, and every specification before this one has
been subordinate to the section it expands the way runtime-loop.md is
subordinate to Section 12. Nothing in Section 28 turned out to be
wrong, which made this easy.
Note: two things were left undone on purpose, both following a
convention the corpus already had rather than a rule anyone wrote down.
ADR-0029 is not added to the engineering plan's "Version 2.0 adds
these ADRs" list, because ADRs 0021 through 0028 are not in it either
— that list is a record of the 2.0 revision rather than an index. And
sandbox-isolation.md is not added to the milestone map's "What
changes in each spec" table, because http-api-and-streaming.md is
not in it either.
Reversal cost: low.
skill_manage is a capability tool, not a control tool¶
Decided: Section 30.2 calls skill_manage a control tool. It is
reclassified as a capability tool, risk: HIGH,
CONDITIONALLY_IDEMPOTENT, requiring the skill.write scope, and
denied when origin_trust is below USER. skill.load stays a
control tool.
Why: tool-system.md draws the control-tool line at state that
does not outlive the run — the four control tools it lists change the
runtime's own state and take no approval, no scope, and no policy
row. A skill revision is a durable row in a tenant's schema. Keeping
the label would have meant relaxing all three constraints for one
tool, which empties the category for the four that belong in it.
Alternative: keep it a control tool and write "except
skill_manage" into each of the three constraints.
Note: this is the only place where a word in the engineering plan is contradicted rather than expanded, and it is contradicted by a specification that eleven documents already build on. Section 30.2's two authoring paths, its four operations, and its "nothing is auto-registered as a tool" are unchanged.
Reversal cost: low now — three lines in tool-system.md and a
heading in skills.md. Moderate after Milestone 8, when the registry
entry and its policy rows exist.
The scope is skill.write, and there is no skill.read¶
Decided: one new scope, skill.write, added to the closed dotted
vocabulary in http-api-and-streaming.md. It gates skill_manage
only. No skill.read scope is created, and skill.write appears in
no route row.
Why: the API specification closes the scope list and matches
scopes exactly with no hierarchy, so a scope that is never checked at
the boundary would still have to be declared to be checkable at the
tool call. Reading the catalog is not an action a principal takes: it
is what a session already does at open, for every session, and a
scope that is granted to everyone is a column rather than a control.
ADR-0013 spells it skills:write, which is not the dotted form the
vocabulary uses.
Alternative: a skill.read scope gating catalog visibility, which
would make an agent with no skills indistinguishable from an agent
whose skills were withheld.
Reversal cost: low. Adding a scope to a closed list is a one-line change and a row in the route table.
skill is a thirteenth gate area¶
Decided: the sixteen gates go in a new gate.skill.* area rather
than being split across context, tool, and policy.
Why: the grammar's rule is that an area names a subject one
specification owns, which is the precedent memory and sandbox
both set, and a security area was rejected earlier for naming a
cross-cutting property instead. One document owns all sixteen. The
split would also have put gate.skill.metadata_only and
gate.skill.authoring_trust in different areas when they are two
halves of one governance argument.
Alternative: three gates in context, eight in tool, five in
policy, and no new area.
Note: this answers the milestone map's own open question 3 — whether Milestone 8 should acquire gates — with yes, and closes the last two milestone rows that showed zero.
Reversal cost: low before Milestone 0's docs check is written, because a gate identifier is a string in four places. Moderate after, because the check parses the area.
The prefix ceiling moves from 13,500 to 15,000 tokens¶
Decided: two context classes are added — a pinned skill catalog at twenty entries and 1,500 tokens, and loaded skill bodies at two skills and 6,000 tokens — and the prefix ceiling that four documents state as 13,500 becomes 15,000.
Why: the catalog is a prefix-region class by construction, since it is pinned at session open and never changes within a session, and 1,500 tokens of it will not fit under a ceiling that was set before it existed. 15,000 is 13,500 plus the catalog, rounded to the nearest five hundred; the 6,000-token body class sits in Region B and is not under the ceiling.
Cost: four edits in bootstrap-and-composition.md and one in
context-engine.md. No gate anchors to the number.
Alternative: hold 13,500 and take the catalog out of the prefix, which would invalidate the cached prefix on every session whose catalog differs — which is every session.
Question for you: 15,000 is a rounding, not a measurement. The first real model fixture will say whether it is generous or tight.
Reversal cost: low. It is a constant in five places and a startup constraint row.
A third skill load fails rather than evicting the first¶
Decided: at most two skill bodies are loaded per session. A third
skill.load returns a structured failure; it does not evict.
Why: a loaded body is sticky for the session precisely so the prefix stays byte-stable, and eviction is the one operation that would undo that. A failure the model can see and route around is cheaper than a cache invalidation it cannot.
Alternative: least-recently-used eviction, which is what a cache would do and what a prefix must not.
Note: the 3,000-token body limit is half the 6,000-token class, so
the two-body cap holds by arithmetic rather than by policy. That
limit is derived from the cap rather than from any procedure anyone
has written, which is the wrong direction for a number to come from
and is skills.md open question 4.
Reversal cost: low.
The authoring loop is Milestone 10 and takes its first gates¶
Decided: the static substrate — package, catalog, load, storage —
is Milestone 8. skill_manage, the background review, and rollback
are Milestone 10. Six of the sixteen gates land there, the first any
document has declared at Milestone 10.
Why: Section 30.5 says to ship the substrate first and enable authoring when the evidence supports it, and Milestone 8 is where the substrate is named. Nothing in Milestones 8 or 9 needs the write path.
Note: Milestone 10 still has no acceptance criteria of its own,
which readiness.md reports as an open question and this does not
answer. Its gate column is no longer zero, but the gates come from a
plan section rather than from the milestone's own criteria.
AGENTS.md says Milestones 0 through 9 are implementable and
Milestone 10 is not to be begun.
Reversal cost: low. The milestone is a field on each gate.
Harness case 27 is a Milestone 8 case with no threshold¶
Decided: one new evaluation case, "A skill changes the outcome", at Milestone 8. Three assertions: the second run succeeds where the first fails, the first run's prefix contains no part of the body, and the two runs' policy dispositions do not differ. No numeric improvement threshold is set.
Why: Section 30.5 gates the authoring rollout on evidence that self-authored skills improve eval cases without increasing policy failures, and the corpus had no case behind that sentence. The case belongs at Milestone 8 because it tests the substrate — a static skill is enough to prove a skill can change an outcome — and putting it at Milestone 10 would leave the substrate untested for two milestones.
Question for you: what improvement counts? Two percent? Five? The
case proves the mechanism; it does not answer whether a given delta
should turn authoring on for a tenant. This is skills.md open
question 8 and it is the one number in that document nobody has the
data to choose yet.
Reversal cost: low. Cases are never renumbered, so 27 stays 27 even if its content changes.
The readiness verdict on skills is corrected, not carried forward¶
Decided: readiness.md says skills have no specification at all
and that no document outside the plan and ADR-0013 mentions
SKILL.md. The second half is true; the first is not, and the
verdict is rewritten where it is stated rather than footnoted.
Why: tool-system.md:1102-1149 is forty-eight lines of real
design — the metadata boundary, the trust label on skill content, the
required_tools check as a note rather than a refusal, and the rule
that a skill's script is not a tool. skills.md had to be written to
fit inside that section rather than on top of it, and a verdict that
says the section does not exist would have made the fit look
optional.
Note: the review's own miss is stated in the document rather than
quietly removed, because readiness.md is a live statement of the
corpus's condition and its value depends on it being corrected in
place.
Reversal cost: low.
Two citation errors are fixed in live documents and left in records¶
Decided: the version-pinning acceptance criterion is at
engineering-plan.md line 2690, not 2684, and the policy-and-approval
gating requirement is Section 30.3, not Section 30.4.
readiness.md and policy-and-approvals.md are corrected;
docs/adr/0005-two-stage-policy-and-approval-model.md and the
2026-07-25 entry in this file are not.
Why: the corpus already separates live statements of current fact from records of what was true at a point in time, and an ADR whose citations are silently updated stops being a record.
Note: the reverse correction was made and undone during this
work. Section 30.4 is loading and lifecycle and it is the right cite
for "only skill metadata enters ordinary context"; Section 30.3 is
governance and it is the right cite for gating, provenance,
versioning, restricted review, injection resistance, and sandboxed
scripts. Two context-engine.md citations were changed to 30.3 in
error and are back at 30.4.
Reversal cost: low.
Harness case gaps and citation integrity (no ADR)¶
Line-number citations are checked mechanically rather than remembered¶
Decided: scripts/check_citations.py and a generated ledger,
docs/status/citation-ledger.yaml, were added. make docs-check now
fails when a file.md:LINE citation no longer holds the text it was
recorded against, and make citations-fix repoints a citation whose
text has merely moved. Nine wrong citations across five documents were
corrected first, so the ledger records a correct state rather than
blessing the current one.
Why: skills.md had just been written to say that a line-number
citation is correct only until the cited file is next edited and that
nothing checks it. A sweep proved the point harder than intended:
thirty-eight citations exist in the live specifications and nine of
them were already wrong, most predating this session's edits. Two of
the nine were induced by insertions made during it. Writing the hazard
down and leaving it unchecked would have been the worst of the three
available outcomes.
Cost: one script, one generated YAML file, one Makefile target, and
about six lines in AGENTS.md. The check is pure Python over the
repository and adds no dependency.
Alternative: delete every line number and cite by section heading instead. That is more robust and was rejected because it would have meant rewriting thirty-eight sentences across five specifications during a conversion that is explicitly forbidden from rewriting requirements, and because several citations point into code blocks and tables that have no heading to name.
Note: the repair is deliberately partial. A citation whose text moved is repointed automatically; a citation whose text is gone, or now appears twice, is reported and left alone, because deciding what such a citation now means is a judgment and not a substitution.
Question for you: do you want the line numbers at all? They are precise and they rot. Heading-relative citations would survive edits and would need no tooling, at the cost of a wide rewrite. My recommendation is to keep them now, since the check makes the rot visible, and to drop them if the check ever starts failing on churn rather than on error.
Reversal cost: low. Deleting the script, the ledger, the Makefile
target, and the paragraph in AGENTS.md restores the previous state,
and the nine corrected citations are correct either way.
The citation check is not written into the Milestone 0 toolchain spec¶
Decided: docs/plan/development-toolchain.md is left unedited. It
specifies the implementation-era Makefile — lint, typecheck,
test-fast, and the rest — and reconciles a target count against
Sections 21, 24, and 25. The citation check exists in the repository's
current documentation Makefile and is recorded in AGENTS.md.
Why: adding a seventh target to a table the specification introduces as "six targets exist that Section 21 does not list" would have meant re-deriving that reconciliation for a docs-only check, and the constraint against rewriting settled requirements applies.
Note: this is a real risk and it is being flagged rather than
solved. When Milestone 0 replaces the documentation Makefile with the
implementation one, the citation check will be dropped unless someone
carries it across. AGENTS.md is the only thing that will remember.
Question for you: should development-toolchain.md name it, at the
cost of moving the count from six to seven?
Reversal cost: low now, higher after Milestone 0 ships a Makefile without it.
delta is promoted to a fifth assertion type, and ADR-0022 says four¶
Decided: the harness assertion vocabulary gains a fifth entry,
cross-arm metric relation, with arms, carry, and delta fields on
the case schema. ADR-0022 says the vocabulary gains four and is left
as written.
Why: two memory gates and Section 30.5's rollout criterion are stated as comparisons between two runs — better on the metric, no worse on policy. A vocabulary of predicates over a single run cannot express that, so the harness could not assert three of its own hard gates. Case 27 already described "runs one task twice and compares" in prose with no schema behind it, and case 31 needed the same shape.
Alternative: express the comparison outside the harness, in a one-off script per gate. Rejected because a gate asserted by a script nobody runs is not asserted.
Note: ADR-0022 is a record of a decision at a point in time and is
not rewritten, per the rule already established for the gate count. The
divergence between "gain four" there and "gain five" in
evaluation-harness.md is deliberate and is logged here so it is not
later read as an error.
Reversal cost: moderate. The three fields are schema, and the schema has not been implemented; after Milestone 8 it is a migration.
"More than half the gates are not case gates" was arithmetic, and wrong¶
Decided: the claim is replaced with the exact count. Sixty-four of one hundred and thirty-eight declared gates are not case gates.
Why: it was false when written — at one hundred and thirty-four declared gates, sixty-three were non-case — and it stayed false as the count grew. The sentence's purpose is to justify building the structural and property tracks in Milestone 0, and a count does that better than a fraction that has to be re-checked every time a gate is added.
Note: two independent derivations now agree on the kind split: seventy-four case, seventeen property, seven corpus, forty structural. One comes from the milestone map's census of registry entries, the other from the harness's own kind table. They were reconciled rather than assumed.
Note: an earlier entry in this file is headed "Roughly a third of the declared gates are not case gates". It was right when written and is left as the record it is. The ratio has moved twice as gates were added, which is the argument for a count rather than a fraction: a count is wrong loudly and a fraction is wrong quietly.
Reversal cost: low.
gate.tool.mcp_disconnect is named for the column, not for clarity¶
Decided: the gate asserting that a mid-call server disconnect
yields unavailable with tool.server_unreachable is named
gate.tool.mcp_disconnect rather than
gate.tool.mcp_disconnect_structured.
Why: gate identifiers sit in a thirty-character column in the
milestone map's tables, and the corpus already truncates two of them
with a trailing ... A third truncation to buy one adjective is a bad
trade, and the shorter name is not ambiguous — there is one disconnect
gate.
Note: the thirty-character ceiling is a table-formatting artifact that has started shaping identifiers. It is worth deciding whether the column should widen before it truncates a name that matters.
Question for you: widen the column and un-truncate
gate.sandbox.workspace_isola.. and
gate.event.checkpoint_dispens., or keep the ceiling?
Reversal cost: low.
gate.harness.mcp_no_socket is a case gate, not a structural one¶
Decided: the gate asserting that the MCP fixture layer opens no
socket is kind case. It was first written as structural, and the
harness kind table and totals were corrected before the count
propagated.
Why: the gate statement asserts the property by running the MCP
cases with egress blocked, not by inspecting the fixture code. That
makes it the same shape as gate.harness.no_egress, which is already
a case gate for the same reason. Kind follows how the gate is
asserted, not what it is about.
Note: this was caught by re-reading the gate statement against the kind definitions rather than by any check. Nothing in the corpus validates that a gate's declared kind matches how its statement says it is asserted, and the two are written in different files.
Reversal cost: low now — one table row and two totals — and higher once the harness is built against the wrong kind.
The readiness verdict table held a gate count four short, and no check reads it¶
Decided: Milestone 8's row in the verdict table of readiness.md is corrected from ten registry entries to fourteen, and its named gap from "MCP auth scheme; the mock server" to "MCP auth scheme". The prose under the table now says where the other four came from. The Milestone 8 section further down was already right; only the summary row was stale.
Why: the four MCP gates added on this pass landed at Milestone 8,
and the table's own stated rule is that the column counts registry
entries whose milestone field names that milestone. The number was
therefore derived, written by hand, and wrong the moment the gates
were registered. It was found by re-deriving all eleven rows from the
registry tables rather than by reading the document — every other row
agrees, and the totals agree three ways: one hundred and thirty-eight
registry entries, the census, and the kind split of seventy-four case,
seventeen property, seven corpus, and forty structural.
Note: this is the same failure as the drifted citations recorded
above, in a different disguise: a number derived by a stated rule but
maintained by memory. The difference is that this one already has a
gate. gate.harness.census_derived requires a test that computes the
census from the registry and compares it to the written table, and it
is a Milestone 0 gate.
Alternative: implement that check now, inside check_docs.py,
the way the citation check was implemented. Rejected, and the
distinction is worth stating because the two cases look alike.
The citation hazard had no gate, no milestone, and no owner — it was
pure documentation maintenance, which is inside this assignment.
The census check is a declared Milestone 0 gate with a specified
home in the evaluation harness, reading a gate registry that does not
exist as data yet. Writing it here would implement a milestone gate
ahead of its milestone and in the wrong place, against the standing
constraint not to begin Milestone 0 work.
Question for you: the verdict table's column is derived from the
registry and drifted within one working pass. Should
gate.harness.census_derived be widened to cover it — the same test,
one more table — or should the column be removed from
readiness.md and the reader sent to the
census, which is the one place the number is stated by derivation?
Reversal cost: low. One table cell and one sentence.
Persistence authoring (ADR-0031)¶
Both Milestone 2 gaps are closed inside the persistence spec, not in a twentieth document¶
Decided: the ORM surface and the Alembic conventions are written into event-log-and-persistence.md as two new sections rather than into a new specification, and recorded as ADR-0031.
Why: that document already owns the schema, the gate.event.*
area, and gate.structure.txn_hygiene. A twentieth spec would need
either a fourteenth gate area or a gate area owned by two documents,
and the readiness review calls both gaps tooling rather than
architecture — they are two sections of material, not a subject.
Alternative: a persistence-adapter.md alongside the others. It
would read more discoverably in the nav and would split ownership of
the schema from ownership of the migrations that build it.
Reversal cost: cheap. Two sections move to a new file, the ADR is re-pointed, and the gate ids do not change.
The ORM shape was forced by the dependency rules, not chosen¶
Decided: row classes are separate declarative types confined to
adapters/persistence/, translation is two hand-written functions per
table in a mappers.py, and a repository is constructed with a live
session and never commits.
Why: declarative mapping of a domain type puts SQLAlchemy inside
domain and fails Section 5's first rule on the import walk.
Imperative mapping avoids the import and fails the seventh rule
silently instead — a mapped class carries instrumentation, so the
domain object becomes the ORM object and every repository return
value violates the rule at the moment the signature check has nothing
left to reject. Pydantic and SQLAlchemy also carry conflicting
metaclasses, so the runtime agrees with the rules.
Cost: roughly twenty tables acquire two boring functions each, all of them needing a test. That is the honest price, and it buys a schema and a wire contract that move independently, and an upcaster with a function to live in.
Note: mappers.py is one module more than Section 4's tree lists.
It is introduced as an addition rather than presented as though it
were always there.
Question for you: the generic field-name mapper is the version
nobody has to write. It was rejected because it turns a column rename
into a KeyError at runtime rather than a type error at check time,
and because it is exactly the mechanism by which a schema change leaks
into an API payload with no one writing a line. Is that trade the one
you want at twenty tables?
Reversal cost: moderate before Milestone 2 ships and expensive after. The mapping shape is set by the first repository written.
Five gates were added, and four of them observe criteria that already existed¶
Decided: gate.structure.migration_graph at Milestone 0, and
gate.event.migration_clean, gate.event.migration_stepwise,
gate.event.revision_pinned, and gate.structure.orm_confined at
Milestone 2. The registry goes from one hundred and thirty-eight
entries to one hundred and forty-three.
Why: Section 24 makes "Database migrations upgrade from a clean database" and "Database migrations upgrade from the previous revision" conditions of every milestone, and nothing evaluated either of them. That is the same defect the milestone map found by counting gates, reached from the other direction: an acceptance criterion no check evaluates is a sentence, not a criterion.
Note: every id is at or under thirty characters, which is the width the milestone-map table column allows before an id truncates.
Reversal cost: cheap while they are prose. Each id appears in the declaring spec, the registry block, the census, and the kind table.
The migration-graph walk registers at Milestone 0, four milestones before the migrations¶
Decided: gate.structure.migration_graph is a Milestone 0
registry entry, tagged **M0.** inside an otherwise all-Milestone-2
hard-gates list in the persistence spec, and the milestone-map section
prose now explains two qualifications where it used to explain one.
Why: Milestone 0's own acceptance criteria already require that an empty Alembic migration runs, which is a graph with one node. A walk that only begins once a dozen revisions exist has already missed the branch it exists to prevent, and a check added against existing violations gets relaxed rather than obeyed. This follows ADR-0024's precedent, where the transaction-hygiene check is a Milestone 0 deliverable and its gate is a Milestone 2 criterion.
Alternative: register it at Milestone 2 with the four others and keep the list uniform. Cheaper to describe, and it would leave the first six migrations unwatched, which is exactly the window in which the conventions get set.
Reversal cost: cheap. One field in the registry and two paragraphs.
Two mis-numbered cross-references were corrected in live specifications¶
Decided: bootstrap-and-composition.md cited "Section 3" for
the AsyncSession unit-of-work rule, which is Section 2.2, and
model-gateway.md cited "Section 3" for the import-boundary tests,
which are required by Section 5. Both are corrected. The engineering
plan's Milestone 0 pointer paragraph, which named eleven registry
entries and one plan-owned gate, is corrected to thirteen and two —
it predates the secret-scanner gate becoming plan-declared.
Why: they are live statements of current fact, not records at a point in time, and both were found by grounding rather than by reading. Section 3 is "Version 0.1 definition of done" and says nothing about either subject, so both citations pointed a reader at a section that would not answer them.
Note: the ADR files and this file are not edited to match. They are records of what was believed when they were written.
Reversal cost: cheap.
A skills.md sentence carried bare line numbers that drift silently¶
Decided: the finding paragraph in skills.md
that reads "which is at 2692; line 2686 is an MCP trust-labelling
bullet" now names engineering-plan.md:2696 as a backticked citation
and refers to the other line by description rather than by number.
One ledger excerpt was repointed by hand.
Why: the citation checker only sees file.md:NNN. A bare number
in prose is invisible to it, so this pass moved the checked citation
and left the unchecked one behind — the two ended up naming different
lines in the same sentence. The paragraph is about citation drift,
which makes it the worst place in the corpus to carry an unchecked
number.
Note: the hand repoint was needed because a citation inside a
cited excerpt takes two --update passes to settle, and the second
pass cannot resolve by content once the excerpt itself has changed.
That is a real limitation of the checker and it is worth knowing
before someone hits it again.
Reversal cost: cheap.
Bare line references (no ADR)¶
Line numbers written in prose are converted, and the form is rejected¶
Decided: thirty-five references that named a line without naming
it in the checked form — "line 1408", "lines 659 to 661",
"tool-system.md 1102-1149" — are converted to file.md:LINE
citations across model-gateway.md,
readiness.md,
runtime-loop.md, and
skills.md. Every target was re-resolved by
content against the current file rather than trusted.
scripts/check_citations.py gains a check_bare_references() pass
that fails make docs-check on either form in a live specification.
Why: the checker only ever saw file.md:NNN, which means the
forms it could not see were the only ones that could rot — the ones
it could see are repaired on every run. They had rotted. Two were off
by more than eighty lines. The ones still correct were correct by
luck, since nothing had ever evaluated them.
Cost: twenty-eight patches across four specifications and forty-three lines in the checker. The ledger grows from thirty-three citations to sixty-five.
Alternative: delete the numbers and cite by heading instead. Rejected on the same argument as the earlier sweep: several targets are rows in tables and lines inside code blocks, and there is no heading to name.
Note: two references were rephrased rather than repointed. A
second occurrence of "line 2202" in the paragraph that had already
cited it became "that fixture asymmetry", and the self-referential
paragraph in readiness.md that named its own wrong line now names
the ledger instead. Repointing a number that a sentence is arguing
about would have kept the sentence and lost the argument.
Reversal cost: cheap for the checker, moderate for the prose.
ADR files become citable targets, and one ADR filename was wrong¶
Decided: docs/adr/*.md is added to the checker's target globs,
last in the list so that a citation naming index.md still resolves
to docs/index.md. Three ADR-targeted citations exist as a result,
all of them new. One of them corrects a filename: skills.md cited
docs/adr/0005-two-stage-policy-and-approval-model.md, which is not
a file in this repository. The ADR it means is
0005-deterministic-policy-engine.md, whose lines 10 and 141 both
still carry the attribution that sentence relies on.
Why: an ADR is a record, but a citation into one is a live statement and rots the same way. The wrong filename had survived because nothing resolved it — a checker that knows three directories cannot report a name in a fourth.
Note: the same wrong filename appears in an earlier entry in this file and is left alone. It is a record of what was believed on 2026-07-25, and the corpus separates records from live statements.
Reversal cost: cheap.
The standing toolchain-spec question is now worth more¶
Decided: docs/plan/development-toolchain.md is still left
unedited, on the reasoning recorded above under the harness section.
Why: none of that reasoning changed. The specification reconciles a Makefile target count against Sections 21, 24, and 25, and a docs-only check is not a Milestone 0 deliverable.
Note: what would be lost at Milestone 0 grew. It is no longer one
script that repoints moved citations; it is also the only thing
stopping thirty-five prose references from coming back one sentence
at a time. AGENTS.md now carries two paragraphs about it and is
still the only thing that will remember.
Question for you: the earlier question stands — should
development-toolchain.md name the check, at the cost of moving its
target count from six to seven? My recommendation has moved from
neutral to yes.
Reversal cost: low now, higher after Milestone 0 ships a Makefile without it.
The Milestone 3 gaps (ADR-0032)¶
The readiness review named three gaps at Milestone 3. Closing all three was one pass, and it made four judgment calls that are cheaper to reverse now than after Milestone 3 ships.
The trajectory export goes in the persistence spec, not a fourteenth one¶
Decided: the export's format, redaction pipeline, consent model,
retention, CLI, and endpoint are all specified inside
docs/plan/event-log-and-persistence.md, in a new section between
Projections and Checkpoints.
Why: the export is a projection over the event log, and that
document already owns the log, the projections, the schema, and the
gate.event.* area. A fourteenth specification would have owned one
feature and borrowed all four.
Cost: the document is now the longest in the corpus.
Alternative: a trajectory-export.md. Rejected for a second
reason as well — "thirteen specs" is a number nine other documents
state, and a fourteenth would have rippled through all of them for a
feature that fits in an existing one.
Reversal cost: moderate. Splitting later is a move plus the ripple that was avoided.
agent run export is a subcommand, not a thirteenth command¶
Decided: export joins get, events, and cancel as a
reserved word after agent run. The CLI still has twelve commands.
Why: the precedent already existed and was already load-bearing.
docs/plan/evaluation-harness.md added four subcommands under
agent eval without changing the twelve, so a subcommand under an
existing command is not a new command. Section 17 states twelve and
bootstrap-and-composition.md's own heading repeated it, which made
the alternative a corpus-wide arithmetic change for one feature.
Note: the composition spec's heading moved from "Three reserved
words" to "Reserved words after agent run", so the next spec that
needs one changes a list rather than a number.
Reversal cost: cheap.
Consent is stamped forward and withdrawn backward¶
Decided: a grant is evaluated at run start and stamped on the run; a withdrawal is evaluated at export time and by the sweeper, and it reaches every run and every artifact already produced.
Why: the two directions are not symmetric and treating them as symmetric gets one of them wrong. A grant is a statement about data the principal has not produced yet, so evaluating it at export time would retroactively authorize every conversation they had before anyone asked. A withdrawal is a statement about data they have, so leaving existing artifacts in place would make it a preference rather than a withdrawal.
Cost: a boolean column on runs at Milestone 2 for a Milestone 3
feature, and a daily sweep that will almost always find nothing.
Note: the column has to precede the first exportable run, because
a run that started before it existed has no honest value to backfill.
Deletion routes through expires_at and the artifact sweeper that
already runs, so the rarest governance operation in the system runs
on the most exercised code in it.
Question for you: one grant covering all export, or separate grants for evaluation use and for support use. One is specified; two is defensible and costs a column.
Reversal cost: low before Milestone 3, high after a tenant has granted anything.
Redaction fails closed rather than redacting twice¶
Decided: the verification scan over the finished document raises
ExportRedactionError, writes no artifact, and names the rule
without printing the match.
Why: the alternative is to redact whatever the scan found and ship the document. That converts a detectable defect in the replacement stage into an undetectable one, and ships the artifact either way.
Cost: an export can fail for a reason the caller cannot fix. That is the correct trade and it needs to be said out loud in whatever the operator reads.
Reversal cost: cheap.
Two defects fixed in passing¶
Decided: sandbox-isolation.md used an undeclared TrustLabel
at two sites where the declared type is TrustLevel, and
ArtifactOrigin had no member for an export. Both are corrected, and
TRAJECTORY_EXPORT is the fifth origin.
Why: the second is not bookkeeping. origin is the field that
says what an artifact is a function of, and an export is the only
origin whose contents are a function of a whole run rather than of a
single act inside it.
Reversal cost: none.
The Milestone 4 builtin tools (no ADR)¶
No workspace. tool resolves a path¶
Decided: all three take a path argument and none of them joins
it, normalizes it, compares it against a root, or opens anything.
Each hands the string to WorkspaceHandle and lets the execution
service resolve it. A structural gate asserts the prohibition by
inspection: the three modules import no os, os.path, pathlib,
shutil, or glob, call no open, and reach the filesystem only
through ToolExecutionContext.workspace.
Why: the alternative is three implementations of one containment rule, and a traversal test that passes because two of the three got it right is exactly the failure being designed out. Three tools written by three people over one sprint is the ordinary case, not a hypothetical.
Cost: the tools cannot do anything clever with paths, which is the point.
Reversal cost: none; loosening it later is a deletion.
Provenance lives on WorkspaceHandle, not in a repository¶
Decided: WorkspaceHandle gains one method and one enum.
write records the resolved path as TOOL_WRITTEN in the same
operation that writes the bytes; provenance reports it;
workspace.read_text returns INTERNAL_TOOL for TOOL_WRITTEN and
EXTERNAL_UNTRUSTED for everything else.
Why: the obvious design is a query against the run's tool
invocations, and it is not available. ToolExecutionContext
deliberately carries no database session, no EventRepository, and
no ToolRegistry, and adding one to answer this question would undo
a boundary that exists for good reasons. The port that owns the
volume is the only thing that can answer honestly anyway.
Alternative: carry provenance on listdir entries rather than
answering per path. Rejected because it widens FileChange, which
several other things read, to serve one caller.
Note: SANDBOX_WRITTEN is defined at Milestone 4, two milestones
before anything can produce it, so that the Milestone 6 implementer
inherits the answer instead of choosing it. Bytes produced by code we
did not write are the case EXTERNAL_UNTRUSTED exists for, and
passing through this port does not launder them.
Reversal cost: low now, high after the first sandbox adapter.
A listing is INTERNAL_TOOL only when every entry is¶
Decided: workspace.list_files returns INTERNAL_TOOL only when
every entry it returns is TOOL_WRITTEN, and EXTERNAL_UNTRUSTED
otherwise.
Why: a filename is attacker-controlled whenever the file is. An
archive extracted into a workspace can create
URGENT_instructions_for_the_assistant.md, and a listing that
renders that as trusted platform text is a prompt injection with a
very small payload.
Cost: in practice almost every listing at Milestone 4 will be untrusted, because almost nothing fills a workspace yet. That is the correct default and it means the rule is exercised from the start.
Reversal cost: none.
The reader has no size-limit failure of its own¶
Decided: workspace.read_text declares no too_large reason
code. A large file becomes a large result and the execution
pipeline's existing excerpt-and-artifactize step handles it.
Why: a second ceiling is a second truncation policy to keep in agreement with the first, and the two would drift the first time either moved.
Cost: the model learns a file was truncated from the pipeline's notice rather than from the tool's failure, which is one indirection.
Reversal cost: cheap.
demo.external_write has no destination and no failures¶
Decided: it writes nowhere, has no side table, and cannot fail.
Its record is the structured result the pipeline already persists
on tool_invocations at step 13, and it carries no timestamp.
Why: its entire purpose is to exercise the approval path. Every byte of machinery beyond that is a second thing to operate for a tool whose value is that it does nothing.
Question for you: whether it should be registered outside development at all. The approval path is exactly what is worth exercising in production, and a production catalog containing a tool that does nothing is either honest or confusing depending on who is reading it.
Reversal cost: cheap either way.
The Milestone 4 gap was narrower than the review said¶
Decided: the readiness review's sentence that "Path traversal is rejected" stood on "an algorithm that no document contains" is now in the past tense, and the finding around it re-tensed with it.
Why: sandbox-isolation.md was written after that review and
specifies WorkspaceHandle.resolve as a five-step containment rule
with a property gate over it. The claim was true when written and
stopped being true with that document. Only the tool-level half was
still missing, and that is what this pass wrote.
Note: two citations into builtin-tools.md:909 broke, both from
the readiness review and both pointing at the sandbox.run_command
Milestone 5 transcription error that sandbox-isolation.md corrected
long ago. They are repointed to builtin-tools.md:1399, where the
corrected statement lives, and the surrounding prose re-tensed to
match.
Reversal cost: none.
The Milestone 4 scope vocabulary (no ADR)¶
One scope vocabulary, not an API one and a tool one¶
Decided: there is one closed set of fourteen strings. Nine were
already enumerated by the API specification and five more appear as
ToolSpec.required_scopes on the builtin roster. They share a
namespace rather than occupying two that happen to use the same
spelling.
Why: artifact.read and artifact.write are two actions on one
resource. If the API owned one vocabulary and the tool system
another, those two would be the same string in both, and the first
time they meant different things the bug would be unfindable.
skill.write settles it on its own: the API document already
describes it as checked by the policy engine rather than by a route,
which is a tool-system check written into an API scope.
Alternative: two prefixed namespaces, api. and tool.. It
makes the boundary visible at the cost of doubling every scope that
crosses it, and every interesting one crosses it.
Reversal cost: expensive after the first profile ships, because the strings are in operator configuration.
An MCP tool may require only its own server's scopes¶
Decided: a declared scope is legal if it is one of the fourteen
or if its first segment is mcp and its second is the server id.
mcp is reserved and cannot be a resource name.
Why: MCP required_scopes are operator-declared, so a list
closed against them is a list the operator routes around. The
escalation worth blocking by construction is an operator declaring
that a remote filesystem-write tool requires session.write — a
line that reads as a restriction and is a grant, because every
principal who can write to a session now reaches a tool nobody
audited.
Cost: an operator who genuinely wants an MCP tool gated on a first-class scope cannot say so. They gate it in the policy profile instead, which is where risk classification belongs anyway.
Reversal cost: cheap; the rule is one registration-time check.
Scopes are opaque strings compared by exact match¶
Decided: the check is a set difference, all-of. No hierarchy, no
wildcard, no prefix rule. run.write does not satisfy run.read,
and a tool that means both declares both.
Why: the alternatives each buy convenience with a question an
implementer has to answer the same way twice. A hierarchy needs a
rule for whether run implies run.cancel; a wildcard needs a rule
for whether run.* reaches a scope added next year. Both rules are
the kind that get written slightly differently in the authorization
check and in the admin UI that displays what a principal has.
Cost: verbose principal configurations. Fourteen strings is a small enough set that the verbosity is bounded.
Reversal cost: cheap in one direction only. Adding a hierarchy later widens every existing principal silently, so it would need a migration that enumerates what each one currently reaches.
The scope denial names the missing scope¶
Decided: policy.scope.missing names the scopes the action
required and the principal lacks. It never names the scopes the
principal holds. This is the one denial in the document that names
anything.
Why: the document's rule is that a denial states the outcome and not the reason, because a reason is a gradient a model can climb. A scope is not a gradient. A model cannot grant itself a scope, and the sentence is being written for the human who reads the transcript and has to work out whether the principal is misconfigured or the tool is over-declared. The held set is withheld on the other half of the same reasoning: it is a map of the surface still worth probing.
Reversal cost: none.
The scope set is stamped on the run, not re-derived¶
Decided: a new runs.principal_scopes JSONB NOT NULL column is
written at submission. PrincipalResolver.for_run reads the stamp
and never a principal table.
Why: a worker holds no credential, so it has nothing to resolve a principal from. Re-deriving at execution would also make the runtime loop's "takes effect on the next run" depend on queue latency rather than on submission order, which is the difference between a guarantee and a usual outcome. ADR-0032 chose the same shape for the consent stamp, and for the same reason: a grant is a statement about work not yet submitted.
Note: approval resumption deliberately ignores the stamp for the revalidation step. A run that resumes after its principal's scopes were narrowed revalidates against the stamp for consistency of the decision it already made, and the next run is denied. Voiding mid-run would make a narrowing operation abort work in flight, which is a different and louder decision than the one an operator thinks they are making.
Reversal cost: expensive; it is a column with a writer at Milestone 2 and a reader at Milestone 4.
JSONB rather than TEXT[] for the stamp¶
Decided: JSONB, matching tool_definitions.required_scopes.
Why: the same data already has a representation in this schema. One concept with two representations is a conversion function that somebody writes twice and gets subtly different both times.
Cost: no array containment operators, which is fine because nothing queries a run by its scopes.
Reversal cost: cheap; one migration.
Principal.roles is declared and not resolved in 0.1¶
Decided: the field is populated and written to the audit record. Nothing reads it as an authorization input. Role-to-scope expansion is a later version.
Why: the field exists on the model already and deleting it would be a churn with no benefit. Reading it in 0.1 would mean a second authorization path that resolves to the same scope set by a different route, and two paths to one answer is where the two answers diverge.
Reversal cost: none; adding the expansion later changes what
fills scopes, not what checks them.
Development mode binds the fourteen and no mcp. scope¶
Decided: AUTH_MODE=dev gives its single principal all fourteen
first-class scopes and no scope in the mcp. space.
Why: a developer should not be blocked by authorization on anything the platform itself ships, and should be blocked by it on anything a server they just connected declares. That asymmetry is the whole point of the reserved prefix, and dev mode is where it is cheapest to notice.
Reversal cost: none.
The approvals resolve route was documented as GET¶
Decided: the route table's
GET /v1/approvals/{id}/resolve row is corrected to POST. It is a
transcription defect, not a design position — the same document's
request body, the policy specification, and ApprovalService.resolve
all describe a state change.
Why: a resolve over GET is a state change a crawler can
perform.
Reversal cost: none.
Where the configured principal's scope set lives¶
Decided: provisionally a Settings field, which keeps the
configuration-file count at six.
Why: the alternative is a seventh configuration file, and the composition specification's own heading counts six.
Question for you: a file would make adding a second principal a
data change rather than a deploy, which is the shape this wants as
soon as there is more than one. The Settings field is right for
0.1 and probably wrong for 0.2, and I would rather you knew that
than discovered it.
Reversal cost: cheap while there is one principal.
The Milestone 7 history predicate (no ADR)¶
Selection is specified inside the context engine, not in an ADR¶
Decided: the history predicate is written into
docs/plan/context-engine.md and no ADR records it.
Why: an ADR earns its place when a decision binds documents that would otherwise diverge. This one binds a single spec's own build sequence and adds one column that the event-log spec already had a place for. The Milestone 4 scope vocabulary was settled the same way and for the same reason.
Reversal cost: cheap; an ADR can be written over a decision already recorded in prose.
Selection is two functions, not one¶
Decided: seeding a run and assembling a request are separate selections with separate inputs, and the readiness finding is treated as covering both.
Why: the finding says "stable across two runs with the same input", and there are two places that phrase can fail. Assembly reads the checkpoint, which is closed and ordered. Seeding reads a projection, which is neither. Writing one predicate over both would have hidden the harder half.
Reversal cost: none.
The retained set is a contiguous suffix, not a ranking¶
Decided: history is a tail of the ordered item list, chosen by a single cut index. No relevance ranking over past turns.
Why: a ranked subset produces a transcript with holes, and a model reading a hole reads a conversation in which the missing thing never happened. It would also be a second retrieval system beside in-turn recall, which already exists to pull back the older thing that matters — and with two of them, a turn that should have been present and was not is ambiguous between a selection defect and a ranking miss.
Alternative: rank by relevance and keep the highest-scoring turns. It sounds better than it is: every objection above is a consequence of it, and none of them surfaces in a demo.
Reversal cost: high once anything depends on the ordering.
Seeding reads the log at a pinned sequence¶
Decided: seed_checkpoint reads session history strictly below
runs.seed_event_sequence, the sequence of the
user.message.created event the run answers, rather than reading the
projection as it stands.
Why: seed_checkpoint has two call sites — run creation and the
rebuild forced when a run's checkpoints are gone — and they can be
hours and a deploy apart. A live read makes the second seed a
different conversation from the first, which is the failure the
Milestone 2 dispensability gate exists to detect and would instead
have been causing.
Cost: one nullable column on runs, written in a transaction
that already allocates the value.
Reversal cost: none while it is additive; expensive after runs exist without it, which is why it is a Milestone 2 column.
Child runs select no session history¶
Decided: runs.seed_event_sequence is null for the child runs of
Section 27.6, and a null sequence selects nothing.
Why: Section 27.6 already says a child returns a concise result and never writes to the parent's conversation. Seeding it from the parent's history would make the relationship asymmetric in the direction that costs the most: unbounded context, a wider redaction surface, and a subagent that can be steered by conversation it was not given on purpose.
Question for you: this is closer to a product decision than an engineering one. A subagent that cannot see the conversation that spawned it needs the parent to restate anything it depends on, and a parent that forgets to will produce a child that confidently answers the wrong question. The alternative is to pass a bounded excerpt the parent names explicitly, which is more work and more honest. I chose the strict version for 0.1 because it is the one that can be relaxed later without breaking anything.
Reversal cost: cheap; the column already carries the value that would be needed.
One gate, not two¶
Decided: gate.context.history_cut at Milestone 7 covers the
assembly half. The seeding half gets no gate of its own.
Why: the Milestone 2 dispensability gate already asserts that a run whose checkpoints were deleted resumes to the same terminal state, which is untestable unless reseeding is deterministic. A second gate over the same property would pass or fail with the first.
Reversal cost: none.
select_history returns an index¶
Decided: the signature returns the cut index, not the retained list.
Why: an index can only describe a suffix. Returning a list would make contiguity an assertion somebody has to remember to write, and the gate would be checking a property the type could have carried.
Reversal cost: none.
A split tool pair moves the cut later, never earlier¶
Decided: when the cut falls between a tool call and its result, the pair is excluded as a unit.
Why: admitting the call instead would add tokens to a set already at its limit, which turns one boundary adjustment into a loop. The pair is an atomic budget unit in both directions, and the cheaper direction is out.
Reversal cost: none.
The cut never falls earlier than the compaction boundary¶
Decided: items below replaced_through_sequence are never
re-admitted to the body.
Why: they are already represented by the summary at position 7. Admitting them again states the same turns twice in two voices, and the paraphrase and the original will not agree about emphasis.
Reversal cost: none.
The token estimator becomes a pure function¶
Decided: TokenEstimator.estimate stays approximate and gains a
requirement that it be a pure function of its arguments.
Why: approximation moves the cut to a slightly wrong place, which is a tuning question. Non-determinism moves the cut to a different place on two identical calls, which is the failure the predicate exists to prevent. Nothing in the earlier wording ruled out a sampled or clock-sensitive implementation.
Note: a cache is still permitted. It may change how long
estimate takes and never what it returns.
Reversal cost: none.
The compaction summary's stated position was wrong¶
Decided: context-engine.md said the summary "sits at position 6
in the assembly order" and now says position 7.
Why: rows 5 and 9 were inserted by the skills specification and this sentence was not renumbered with them. Position 6 is the session-open memory snapshot.
Reversal cost: none.
The Milestone 8 MCP authentication gap (no ADR)¶
Authentication is specified inside the tool system, not in an ADR¶
Decided: the authentication scheme is written into
docs/plan/tool-system.md and no ADR records it.
Why: an ADR earns its place when a decision binds documents that would otherwise diverge. This one adds four columns to a table that document already owns and three gates to a list it already keeps. It touches the sandbox spec only by reading its tier-0 variable list rather than copying it, which is the opposite of divergence. The Milestone 4 scope vocabulary and the Milestone 7 history predicate were settled the same way and for the same reason.
Reversal cost: cheap; an ADR can be written over a decision already recorded in prose.
The scheme is configuration, not something the broker infers¶
Decided: mcp_servers gains an auth_scheme column. The broker
resolves a reference to bytes and is never asked what those bytes
are for.
Why: a bearer token and an OAuth client secret are both opaque
strings. A resolver that infers between them is a resolver that
eventually presents a client secret as a bearer token to a server
that logs its Authorization headers, and the failure is silent on
our side and permanent on theirs.
Alternative: store the scheme in the secret alongside the value,
which some brokers support. Rejected for three reasons that each
stand alone: validating a row would then require dereferencing a
secret, secrets rotate and protocols do not, and the scheme appears
in operator-facing errors and in mcp.server.disconnected, which is
a place the emitter is forbidden to look for secrets.
Reversal cost: none.
Five schemes, and no mutual TLS¶
Decided: the closed set is none, bearer, header,
oauth2_client, and env. A client certificate is not among them.
Why: the five cover every deployment shape the corpus already describes, and a closed set is what makes write-time validation possible at all. Mutual TLS is left out because a client certificate is a property of the connection rather than of the request, so it belongs in the egress proxy's configuration rather than in a row that describes what to put in a header.
Question for you: if an operator asks for mutual TLS, the answer is to configure it at the proxy, not to add a sixth scheme. Say so if you disagree, because the sixth scheme is the cheaper-looking option and someone will propose it.
Reversal cost: low for a sixth scheme, higher for mTLS, which would want a place to put a certificate and a key rather than one reference.
Configuration is validated when written, not when dialled¶
Decided: every rule about schemes, names, transports, and token endpoints is checked at the point the row is written.
Why: a bad row should be a configuration error a human sees immediately, not a connect failure a tenant discovers when a tool they were advertised does not work. It also means the checks run with no server and no broker, which is why the gate over them needs neither.
Cost: the validator has to know things the dialler also knows,
including the egress allowlist and the sandbox's tier-0 variable
list. The list is read from sandbox-isolation.md's definition
rather than copied, so the two cannot drift.
Reversal cost: none.
Three gates rather than one¶
Decided: gate.tool.mcp_auth_config,
gate.tool.mcp_reauth_bounded, and gate.tool.mcp_stdio_env_built.
Why: they divide by what each needs in order to run. The first needs a validator and nothing else. The second needs a server that will return 401 on demand. The third needs a child process whose environment can be read back. One combined gate would be unrunnable until the last of those three fixtures existed, which would put the cheapest check behind the most expensive one.
Reversal cost: none.
A 401 buys one re-authentication and at most one retry¶
Decided: the ladder runs at most once per server per session, and the retry it permits is the retry the recovery table already permits.
Why: routing through the recovery rules rather than around them
keeps mark_effect_sent the thing that decides retryability. A 401
arriving after an effect was sent says nothing about whether the
effect landed, so a non-idempotent call in that state is UNCERTAIN
and is not retried. A ladder that made its own retry decision would
be a second, quieter answer to a question the spec already answers.
Note: if the re-resolved credential is byte-identical to the one that failed, the retry is skipped. Presenting the same rejected bytes again is a round trip that cannot succeed.
Reversal cost: none.
Expiry is checked at use, and there is no background refresh¶
Decided: the transport compares recorded expiry against the clock when it builds a header, with a fixed sixty-second skew. Nothing refreshes on a timer.
Why: a background refresh is a second clock in a system that already has a lease epoch and a worker heartbeat, it keeps tokens alive for connections nobody is using, and it does not remove the 401 path, because a token can be revoked before it expires. Check-at-use costs one comparison on a path that is already doing I/O.
Reversal cost: none.
Refresh tokens are not used¶
Decided: the oauth2_client scheme re-runs the client-credentials
grant and stores no refresh token.
Why: the grant is not supposed to issue one. A server that does is either misconfigured or running a different flow, and holding a long-lived credential we did not ask for is a liability with no corresponding capability.
Reversal cost: none.
The user-delegated OAuth flows are deferred, and said so¶
Decided: authorization code and dynamic client registration are
out of scope for 0.1. A server that requires one fails to connect
with tool.auth_unsupported.
Why: they need a browser redirect, a callback URL, and a
per-principal token store. conversation.ask_user suspends a run to
collect text, not to collect a redirect, so there is no existing
surface to hang this on. Failing to connect with a named reason is
better than a scheme that half works.
Question for you: the unlock is an interactive authorization surface on the HTTP API, which is a real piece of product and not just a protocol chore. If you want to connect to servers that only speak user-delegated OAuth before 0.1 ships, this needs to move up and it needs a design of its own.
Reversal cost: moderate; the scheme vocabulary has room, but the token store and the redirect handling are new.
A stdio server's child environment is built, not inherited¶
Decided: the child receives the synthesized sandbox tier plus the
one declared credential variable, and nothing else. The credential
never reaches argv.
Why: inheritance is the default behaviour of every process-spawning API in the standard library, and what would be inherited here is the worker's database URL and every provider key, handed to an operator-configured third-party process. That is why it is a gate with a planted sentinel rather than a sentence in a paragraph.
Cost: a server that expected some ambient variable will fail, and the fix is to declare it, which the schema does not currently allow for anything but the credential. That is deliberate for now.
Reversal cost: low; a declared extra-environment field is an additive change.
Four pre-existing defects fixed on the same pass¶
Decided: four sentences that had fallen behind what they describe
were corrected rather than left for the next reader.
tool-system.md's availability table said "Three different things
can make an advertised tool uncallable" over a table that already had
four rows, and now says "Several things ... and every one of them".
evaluation-harness.md said "seventy-one of the hundred and fifty-six
declared gates are not case gates", stale from before the Milestone 7
gate, and now says seventy-three of one hundred and sixty.
milestone-map.md's opening said "one hundred and fifty declared
across thirteen specs, one hundred and fifty-six registry entries",
stale by one from the same pass, and now says one hundred and
fifty-four and one hundred and sixty. Its third open question said the
MCP half "still registers no invariant of its own", which stopped
being true when gates 11 through 13 were added, and now records both
later passes.
Why: three of the four are counts, and a count that has fallen behind is the failure the derived census exists to prevent. The prose numbers are not generated, so they are worth re-deriving on every pass rather than trusting — which is how three of these were found, by grepping the corpus for every spelled-out census figure after the new ones were written.
Note: the harness one also carried a ninety-seven character line in a file whose convention is eighty-three.
Reversal cost: none.
The Milestone 9 knowledge-document gap (ADR-0033)¶
A fourteenth specification rather than a section in either memory document¶
Decided: knowledge documents get their own specification,
docs/plan/knowledge-documents.md, and their own ADR.
Why: a belief and a document answer different questions. A belief answers what is true and the unit of retrieval is the claim; a document answers what does the source say and the unit of retrieval is the passage, quoted verbatim and cited. Both memory specifications open with a scope line that says beliefs and episodes, so hosting knowledge inside either one means contradicting the sentence the document starts with.
Alternative: a long section in
memory-retrieval-and-ranking.md. Rejected on the same test that
rejected a fourteenth spec once before, for trajectory export — does
the new subject own what it needs, or borrow it. Trajectory export
borrowed almost everything. Knowledge owns ten things: the document
model, ingestion, chunking, the index, the scope model, retrieval
over chunks, rendering and citation, retention, the tool surface, and
its gates. It borrows four: the artifact store, the event log, the
budget, and the trace.
Cost: three live sentences stated "thirteen specs" and had to be corrected, and the census had to be re-derived. That was the whole ripple, and it was measured before the decision rather than after.
Reversal cost: low while nothing is built. The document could be folded into the memory spec by concatenation.
knowledge is a fourteenth gate area¶
Decided: twelve gates in a new area, gate.knowledge.*.
Why: the same precedent skill set at the thirteenth area. An
area names a subject, one specification owns the subject, and the
gates are statements about that subject rather than about the
repository.
Alternative: folding them into memory. memory already holds
two specifications sharing an area, so the precedent exists — but it
holds two specifications about one subject, which is the reason it
was allowed to. Knowledge is a second subject.
Note: the identifiers are shorter than they read naturally
because the milestone map's id column is thirty characters and
gate.knowledge. consumes fifteen of them. no_secrets, not
no_secrets_ingested.
Ingestion is a tool, not a route and not a CLI noun¶
Decided: one builtin, knowledge.ingest, admitted through the
tool pipeline.
Why: two closed lists said no to the alternatives.
http-api-and-streaming.md closes the API at thirteen routes and
states that an artifact is not uploaded through this API in 0.1, so a
knowledge upload route would reopen a list that was deliberately
shut. The CLI is closed at twelve nouns. A tool needs neither list
reopened, and it puts admission through the policy engine, the
approval path, and the event log without any new machinery.
Cost: ingestion is only reachable from inside a run. An operator bulk-loading a corpus has to drive it through the agent, which is slower than a route would be.
Question for you: if bulk loading matters before 0.1, the answer is a management surface rather than a fourteenth route — say so and it moves up.
Later: ADR-0120 (2026-09-23) adds a chat attachment upload. It is not a knowledge route: a file reaches knowledge from it only when the owner sends it in a message, through the same service and scans.
The secret scan blocks an ingest; the injection scan does not¶
Decided: a detected credential refuses the whole ingest and
nothing is written. Instruction-like text is recorded on the chunk as
instruction_like and ingested anyway.
Why: the two failures are not symmetric. A credential in a
permanent, retrievable corpus is unrecoverable — it is quoted back
verbatim into a context window by design, which is the one place a
secret must never be. Instruction-like text is survivable by
labelling: the passage arrives inside a <knowledge> block at
TrustLevel.KNOWLEDGE, the policy engine already refuses to treat
untrusted context as instruction, and a blocking injection scan would
refuse most real technical documentation, because a deployment guide
is mostly imperative sentences.
Reversal cost: low. The flag is on the chunk, so a later policy could filter on it.
No chunk overlap, and heading paths instead¶
Decided: chunks are structure-first with a target of six hundred tokens, a ceiling of a thousand, a floor of a hundred, and no overlap.
Why: the citation is the chunk id. Overlapping chunks mean the same sentence has two ids, which makes a citation ambiguous and makes deduplication a ranking problem rather than a fact. Overlap exists to restore context a hard cut removed; a heading path restores it cheaper and exactly, and a document that needs the neighbouring chunk can be asked for it.
Cost: a claim split across a chunk boundary is retrievable only as two passages. The floor of a hundred tokens and the structure-first split make that rarer than a fixed-window splitter would.
Note: the chunker is deterministic under a chunker_version
because a citation that stops resolving after a library upgrade is a
broken citation, and there is a property gate on exactly that.
Visibility replaces the principal id as the isolation predicate¶
Decided: a document carries visibility in {principal, project,
tenant}, and retrieval filters on it rather than on principal_id.
Why: this is the exact inverse of the rule the memory layer took, and the inversion is the point. A belief travels unless it is pinned, because you asked the agent to learn across projects. A document is shared unless it is scoped, because a document is a thing an organization has rather than a thing an agent inferred, and the common case for admitting one is that more than one person should be able to quote it.
Cost: the default is the widest of the three, so a mis-set visibility over-shares rather than under-shares. The gate on it is a case gate rather than a property gate for that reason — it is worth an explicit scenario.
Knowledge passages get their own budget class and yield first¶
Decided: a Region B class of three passages or three thousand tokens, first in the yield order, ahead of in-turn recall.
Why: a passage is roughly three times the weight of a belief and
cannot be trimmed by sentence without becoming a misquotation, so it
cannot share recall's elastic allowance. It yields first because the
corpus is still there — an explicit knowledge.search re-reaches it
in one tool call, which is not true of a frozen snapshot.
Alternative: taking a share of the two-thousand-token recall class. Rejected because the two classes then compete on a single threshold, and a long document would silently evict beliefs.
Passages drop whole, never truncated¶
Decided: when the class overflows, the lowest-ranked passage is removed entirely.
Why: the model is going to quote and cite what it is given. A passage shortened to fit is a misquotation attributed to a real document, which is worse than the passage being absent. There is a property gate asserting the rendered text matches the stored chunk exactly.
No supersession collapse, no per-subject cap, no conflict surfacing¶
Decided: none of the three memory-retrieval mechanisms carry over. A per-document cap of two passages replaces them.
Why: all three exist because beliefs make claims that can contradict each other, and the retrieval layer is where the contradiction has to be resolved before the model sees it. Passages do not make claims — they report what a source says, and two sources disagreeing is information rather than a defect. The per-document cap exists for a different reason: to stop one long document from filling the whole class.
One new scope, knowledge.write¶
Decided: the closed vocabulary goes from fourteen strings to fifteen.
Why: ingestion is the one privileged act here, and the vocabulary is closed precisely so a new privileged act has to be enumerated rather than invented at the call site. Reading is governed by visibility rather than by a scope, because a scope that everyone holds is not a control.
The management surface is deferred; the deletion semantics are not¶
Decided: no list, browse, or re-ingest surface in 0.1. Deletion semantics are fully specified anyway.
Why: the surface is a product decision that wants a UI, and
nothing in Milestone 9 needs it. Deletion is different — a corpus
with no defined deletion path is a retention problem the moment the
first document is admitted, and the mechanism costs nothing because
it reuses ADR-0032's consent-withdrawal move: a source artifact is
stored with no expires_at, and deleting the document sets one so
the existing sweeper collects it. There are gates on the cascade and
on citations resolving rather than dangling.
Question for you: if you want a management surface before 0.1, it is a small API document rather than an addition to this one.
Section 29's Device model (ADR-0034)¶
An audit of the seam rather than a design¶
Decided: the seventeenth specification does not design the
Device. It walks the corpus for every place a deferred device
design will have to touch, and records what it finds.
Why: Section 29's own last subsection says "Defer the Device concept, presence, device-scoped tool routing, and notifications to a milestone with concrete use cases", and the plan's sequencing table puts inbound surfaces and pairing at Milestone 10. Writing contracts for the four ports Section 29.6 names would be building the thing the plan defers, inside a document whose job is to expand the plan rather than overrule it.
Alternative: write the design anyway and mark it deferred. Rejected because a design nobody is allowed to build is a design nobody reviews, and it would have been the only specification in the corpus that no milestone reaches.
Reversal cost: low. The audit is a list of places, and a later design consumes the list rather than replacing it.
The document declares no gates¶
Decided: no ## Hard gates section. The census stays at 166
declared, 175 declarations, 172 registry entries.
Why: a gate is a test that runs against built code, and this
document authorizes no code. The one obligation Section 29 actually
places on 0.1 — that a second client can attach to a session and
replay it — is already a hard gate somewhere else,
gate.api.replay_exact at Milestone 5.
Cost: gate-less specifications go from two to three. The other
two, bootstrap-and-composition.md and development-toolchain.md,
are gate-less for the same reason: they describe arrangement rather
than behaviour.
The four Section 29.6 ports get a placement and nothing else¶
Decided: each of the four is named with the module it will live in, and no signature is written.
Why: the readiness review recorded that the model had no home,
which is a question about placement rather than about contracts.
bootstrap-and-composition.md already answers placement in general:
a port lives in the module named for the capability it abstracts,
not for the component that calls it. Applying a rule that already
exists is not new design; writing four method signatures would be.
Note: this is why the document is useful at all. A placement can be checked against the rule today. A contract could only be checked against a use case nobody has yet, which is the same use case Section 29.8 says it is waiting for.
A device that attaches mid-session is invisible until the next one¶
Decided: presence does not change what a session advertises. A device that attaches after a session opens contributes no tools to that session.
Why: tool-system.md pins the advertised tool prefix at session
open and resolves availability at call time, and it already handles
the disconnect direction — a device that goes away yields
unavailable with tool.device_offline. The attach direction has
the same shape as an MCP server sending
notifications/tools/list_changed, which the same document records
and does not apply, with the new tools available at the next session
open. Resolving attach any other way means two rules for one
behaviour.
Question for you: the user-visible cost is that plugging in a phone mid-conversation does not make the phone's tools usable until the next session. That may be exactly wrong for a product whose premise is multi-device. If it is, the fix is to unpin the prefix for every source at once, not to special-case devices.
Per-device scopes are an intersection, and never a new prefix¶
Decided: a per-device scope set is the intersection of the principal's scopes and the device's, computed once when the run is submitted, and every string in it comes from the closed fifteen.
Why: runs.principal_scopes is stamped at submission and
PrincipalResolver.for_run reads the stamp and never a table, which
is hard gate 13, "The scope set is the run's." An intersection
computed before the stamp needs no change to the policy engine at
all — the engine is never told a device exists. The constraint that
travels with it is that a device may not introduce a device. scope
prefix, because the vocabulary is closed at fifteen strings with one
exception for mcp, and hard gate 11 enforces that grammar. The
device. that exists today is a tool-name domain, an unrelated
namespace that happens to share a word.
Reversal cost: low now, high later. Making device scopes anything other than a subset means changing the grammar gate, the stamp, and the resolver together.
Device lifecycle events are named as a gap, not placed¶
Decided: the document records that a device attaching, detaching, or being revoked has nowhere to be written, and stops there.
Why: events.session_id is NOT NULL and the uniqueness
constraint is (session_id, sequence), so the append-only log has
no shape for an event that belongs to a principal rather than to a
session. Every fix is a schema change: a nullable column, a second
log, or a synthetic session. Choosing among them is design work on
the deferred subject, and choosing wrong is a migration.
Question for you: where should device lifecycle events go? My
weak preference is a separate audit table rather than a nullable
session_id, because that constraint is doing real work everywhere
else in the log.
NotificationService is a name with nothing behind it¶
Decided: recorded as a gap. Not defined here.
Why: the name appears exactly twice in the whole corpus, once in the plan's port list and once in ADR-0011, and neither says what it does. It could be one port or two: pushing a message to a device is a transport concern, deciding whether to notify at all is a policy concern, and the two have different testability.
Question for you: one port or two? Two is my weak preference, on the same split the corpus already draws between the tool broker and the policy engine.
Three plan sentences are corrected in the specifications' favour¶
Decided: where Section 29 and a specification disagree, the specification wins and the plan sentence is the one recorded as needing correction.
Why: the specifications are newer, they carry gates, and the plan delegates to them by name. Concretely: the registry accepting new entries from "exactly two sources" is wrong once attach exists, because attach is a third; Section 29.5's "queue or reject" is reject, because ADR-0004's partial unique index makes a second active run on a session impossible to enqueue rather than merely unwise; and presence-based exposure yields to the pinned prefix, as above.
Note: none of the three is edited in engineering-plan.md on
this pass. Correcting them in place would settle the deferred
design's conflicts before the design exists. Recording them means
the implementer meets each one with the resolution already attached.
A paired Surface is a Device with an empty capability set¶
Decided: no second model. A Surface — a phone, a chat client, an
email bridge — is a Device that advertises no tools.
Why: the two differ in what they can do, not in what they are. Both attach to a principal, both are clients of the same API, both need the same revocation path. A second model would duplicate identity, pairing, and revocation in order to express "and it has no tools", which an empty set already expresses.
Question for you: what trust label does a message that arrives
through a paired Surface carry? A message a user types on their own
phone is USER. A message that arrives as an email through a bridge
is not, and the corpus has no rule yet for the middle.
The order the deferred work lands in is left open¶
Decided: the five gaps are listed with what each will cost, and not ordered.
Why: even the cheapest sequencing question — whether the
Device table lands before device-scoped tool routing — has an
answer that depends on which use case forces the work, and Section
29.8 defers precisely until there is a concrete use case. Ordering
them now would be inventing that use case.
Question for you: if you want an order anyway, my weak preference is identity first — the table, pairing, and revocation — with routing and notifications after. Identity is the part every other piece needs, and it is the part that is a migration.
Milestone 10 and readiness open question 4¶
Answered by measuring, not by adding a heading¶
Decided: Milestone 10 is correctly an open direction. It gets no
#### Acceptance criteria heading and no implement list.
Why: the question was whether the missing criteria are an omission or a choice, and the way to tell is to look at what the milestone's own conditions are. Two of its four parts gate on evidence rather than on a date — "Implement a scheduler only after durable on-demand runs are reliable" and "Add subagents only when evaluation evidence shows that a single agent fails". Acceptance criteria are a promise about a delivery. A promise cannot be made about work whose own entry condition says it must not start until evidence arrives, and inventing one would be worse than the missing heading, because every other milestone's criteria are promises this plan can keep.
Question for you: if you would rather Milestone 10 read uniformly with the others, the honest form is a heading that states the entry gates as the criteria — "subagents are not built until evaluation evidence shows a single agent fails for one of five named reasons" is a criterion, just not a delivery one. I did not write it because it restates the gate rather than adding anything.
The review's own subagent count was stale¶
Decided: corrected in place, with the finding kept and re-tensed rather than deleted.
Why: the readiness verdict said five of the nine subagent
requirements had no design, naming restricted context and the child
deadline among them. Both are designed now. context-engine.md:278
makes runs.seed_event_sequence nullable for child runs, which
"seed from a parent's concise instruction rather than from session
history", and memory-retrieval-and-ranking.md:87 gives child runs
their own recall class at fifteen beliefs against an interactive
run's forty. runtime-loop.md:1141 says "the parent's
deadline_at is copied onto every child at creation". All three
documents were written after that verdict. Five of the nine are
supplied, two are partial, and two still have none.
Question for you: none. This is a correction of fact, and the house rule is that a review's findings are kept as the record with the closing appended, which is what I did.
A child run cannot be inserted into its parent's session¶
Decided: recorded as a conflict in the readiness review, not resolved.
Why: event-log-and-persistence.md:724 enforces Section 27.5's
one active run per session with a unique index on session_id where
status is not COMPLETED, FAILED, or CANCELLED. A parent
suspended on a child waits in WAITING_FOR_APPROVAL carrying a
typed suspension kind — runtime-loop.md:290 chose that over an
eighth status — and that is not one of the three. So the parent row
still occupies the session, and a child row in the same session
violates the index. Section 27.6 offers "the parent's session or a
dedicated child session per policy"; only the second is
implementable as the schema stands, and no policy is written to
choose between them.
Question for you: resolving it is Milestone 10 work and this documentation pass authorizes none, so it stays recorded. When it comes up, my weak preference is to delete the branch rather than write the policy: a dedicated child session always, because the alternative needs the index predicate widened to exempt child runs, and that predicate is the thing keeping one active run per session true.
delegate.run has a carrier and no schema¶
Decided: noted as partial, not designed here.
Why: tool-system.md:916 registers delegate.run as a control
tool that spawns a child run and suspends the parent, and
context-engine.md:278 says the child seeds from a parent's concise
instruction. Nothing types that instruction. The plan's "explicit
objective" requirement therefore has a delivery path and no input
schema anywhere in the corpus.
Question for you: whether the objective should be a plain string or a structured brief with a success condition. I have no view worth recording, because the answer depends on what the first real delegation is for, and Milestone 10's own gate says that should be driven by evaluation evidence.
The closing adversarial pass¶
Ten findings, eight confirmed and two cleared¶
Decided: run one pass whose only job was to falsify the claim that the corpus is complete, then verify every finding it raised against the sources before treating any of them as fact.
Why: the corpus had reached the point where every plan section
had a specification and no specification named a blocker, which is
exactly the point at which a completeness claim is least likely to
be tested. So the pass was prompted to falsify the claim rather
than to confirm it. It raised ten findings. Eight survived checking
and are corrected in this pass. Two did not: one was an explicit
deferral, and readiness.md:56 already rules that a deferral
recorded as a deferral is not a gap, and the other was already an
open question in the document it was raised against and already an
entry in this file.
Question for you: none. The pass is the kind of work the standing instruction covers, and everything it produced is in the corrections below.
The approval routes are Milestone 5, and an earlier verdict was wrong¶
Decided: narrow policy-and-approvals.md's build step 11 to
the approval service read methods and the CLI commands that call
them, and leave all three approval routes at Milestone 5 with every
other route in the API.
Why: an earlier pass recorded this contradiction with the
opposite verdict, reading http-api-and-streaming.md as the
document in error. Checking the sources reversed it. Milestone 5's
implement list in the plan is the entire HTTP surface, down to the
error envelope and the health endpoints, and Milestone 1 has no
HTTP API at all, so no route can land before Milestone 5. And what
agent approval list calls is the application service, not the
route: policy-and-approvals.md:994 says so in the same paragraph
that adds the routes, and decision 3 of
http-api-and-streaming.md is the reason, because a service that
raised an HTTP error would make the CLI import a web framework. So
Milestone 4 owes the routes their service, which is what "Approval
API and CLI" in Section 21's Milestone 4 implement list means here,
and Milestone 5 owes the routes.
Question for you: none, but this one is worth knowing about. Had the earlier verdict been trusted, three routes would have moved to the wrong milestone on the strength of a summary rather than a source. Every finding in this pass was checked against the files before it was written down, and this is the one that changed sign.
skill_manage is not a name the registry can hold¶
Decided: the registered name is skill.manage. The correction
is stated once in tool-system.md, which owns the grammar, and
carried by the normative spec block in skills.md. The plan's
spelling stays in prose everywhere the string does not matter.
Why: the name grammar at tool-system.md:336 requires at least
one dot in every registry name, and a capability tool is a registry
entry. The skill domain already holds skill.load and the
skill.write scope already names the capability, so the dotted
spelling is the one the domain partition table was built for and no
new domain is needed. The alternative was a corpus wide rename of
every occurrence, which would have rewritten the plan's own
requirement text. The corpus has a precedent against that:
engineering-plan.md:3324 reclassified this same tool from control
to capability with a pointer paragraph rather than a rewrite, and
that paragraph now carries both corrections.
Question for you: whether the prose should be renamed too. My view is that it should not. The plan's requirement lines are the record of what was asked for, and the one string that has to be exact is now exact in the two documents that specify it.
The memory management surface is three port methods and nothing else¶
Decided: recorded and sharpened in the readiness review, not designed.
Why: the review already listed this among five partials, and it
is sharper than the review recorded. The MemoryStore port
declares list, edit, and delete at
memory-formation-and-consolidation.md:357, and no tool, route, or
command in the corpus calls any of them. The agent facing surface
is two tools, memory.search and memory.recall_episodes, and
both read. None of the twelve CLI commands is a memory command, and
no memory route is registered. So "A user can inspect and delete
stored memories" at engineering-plan.md:2773 is the one
Milestone 9 acceptance criterion with nothing behind it to test,
and the three port methods are the shape of an answer rather than
the answer.
Question for you: what the surface should be. A management tool
on the skill.manage precedent, a route set, a CLI command, or all
three. I did not choose, because edit semantics over an append only
belief store that carries provenance and supersession is real
Milestone 9 design and this documentation pass authorizes none.
Reversal cost of deciding later is cheap; the cost of deciding
now and deciding wrong is a migration.
Resolved 2026-08-18: the repository owner authorized closing the gap immediately. ADR-0045's CLI choice is implemented without expanding the closed HTTP route set: belief list/get/edit/delete, source-session filters, consolidation-run inspection, and principal-scoped retrieval-trace inspection.
Five smaller corrections, and what the citation check cannot see¶
Decided: fixed in place.
Why: two were cross references that pointed at the wrong place.
readiness.md cited engineering-plan.md:3215 twice for a
requirement that sits at 3227, and
bootstrap-and-composition.md sent a live provider job to Section
20.6 instead of 20.4. One was a description of a sibling document
that the sibling had outgrown: skills.md said tool-system.md
classified skill_manage as a control tool while also giving it
NON_IDEMPOTENT, which was true of a draft and is not true of the
document. One was a sentence in milestone-map.md that still read
as though Milestone 10 added no gates of its own, after skills.md
gave it six; it now records the six and narrows the standing
question to the routing and subagent half. And Section 31 of the
plan had no outward pointer to the documents that expand it, which
every other expanded section carries.
Question for you: none, but the first and third are worth
naming as a class. scripts/check_citations.py --update rewrites
the ledger with whatever text sits at the cited line, so it cannot
see a citation that points at the wrong line, and it cannot see a
quotation in the citing document that no longer matches the cited
text. Both were found by reading. If the check is ever extended,
that is the gap to close.
Thirteen registry rows carried an identifier the grammar forbids¶
Decided: the four affected tables in milestone-map.md widen
their identifier column, and every row in the registry is written
in full.
Why: the document sets the grammar itself. An identifier is
gate.<area>.<slug>, where the slug is lowercase,
underscore-separated, and unique within its area. A slug holds no
dots, so gate.runtime.one_terminal_wr.. is not an identifier, and
two of this document's own hard gates fail on one. Gate 5 asserts
that every identifier matches the grammar, and a truncated one does
not. Gate 2 asserts that the registry and evals/gates/*.yaml hold
the same identifiers, compared as sets and not as counts, and a
truncated form cannot be a set member of anything. The corpus was
carrying thirteen such rows across four tables, which no
implementation of either gate could have accepted.
Note: this answers a question already on the record. The entry
headed "gate.tool.mcp_disconnect is named for the column, not
for clarity" asked whether to widen the column or keep the
ceiling, and named two truncations. The audit found twelve gates
across thirteen rows, the extra row being an alias restated in a
second table. The ceiling is gone. The naming decision that entry
recorded stands on its own reasoning, since there is one disconnect
gate and the short name is not ambiguous, but the constraint that
produced it no longer exists and no future name needs shortening to
fit a column.
Note: make docs-check passed green with all thirteen in
place, before the audit and after the fix. Nothing in the toolchain
reads a gate identifier.
Reversal cost: low.
Three runtime gate identifiers had never been spelled in full¶
Decided: runtime loop gates 1, 11, and 12 are registered as
gate.runtime.one_terminal_writer,
gate.runtime.waiting_holds_nothing, and
gate.runtime.cancel_keeps_effects.
Why: nine of the twelve truncated gates were spelled in full
somewhere else in the corpus and were restored from there, the
eight sandbox ones from sandbox-isolation.md and
gate.event.checkpoint_dispensable from both
event-log-and-persistence.md and runtime-loop.md. The other
three existed nowhere in full. Their declarations in
runtime-loop.md read "One terminal writer", "A waiting run
holds nothing", and "Cancellation never abandons an effect", and
each completion above is the reading consistent with both the
surviving prefix and the declaration. The third has independent
corroboration: the census in milestone-map.md already describes
Milestone 5 as "the API surface, the stream, cancellation keeps
effects".
Question for you: confirm the three spellings. They are the only identifiers in the registry that this pass named rather than recorded, and every other one in the corpus can be traced to a document that already stated it.
Reversal cost: low now — four table rows and this entry — and
higher once evals/gates/runtime.yaml exists and tests reference
the identifiers.
The harness's worked examples named gates that do not exist¶
Decided: the second evals/gates example and the agent eval
gates output example in evaluation-harness.md are corrected to
use registry identifiers, and the two milestone counts in the
second are corrected against the census.
Why: they used gate.policy.prompt_is_not_authorization and
gate.tool.watermark_contract. The registry holds
gate.policy.prompt_not_authz and gate.tool.watermark_first.
Gate 2 of milestone-map.md compares the registry against
evals/gates/*.yaml as sets, so a worked example of that very file
naming an identifier the registry does not hold is a worked example
that fails the gate it illustrates. The document's first example
uses a real entry with its real kind and milestone, so this is a
deviation from the document's own convention rather than a licence
to invent.
Note: gate.tool.watermark_first is a case gate at Milestone
1, and the example is a Milestone 4 listing, so it could not be
renamed in place. It is replaced by gate.builtin.listing_stable,
a property gate at Milestone 4, which keeps the example's point
that a milestone listing mixes areas.
Note: the counts read 28 gates for Milestone 4 and 12 for Milestone 5. The census says 22 and 11, and 28 is Milestone 1's count. The pass and pending figures move with them so the example still adds up, and the sentence below it that read "the twelve gates arriving next" now reads eleven.
Reversal cost: low.
Nothing in the toolchain reads a gate identifier¶
Decided: the audit stays a record and is not turned into a
check in scripts/.
Why: the checks that would have caught all of this are already
specified. They are gates 2, 5, and 6 of milestone-map.md, all
Milestone 0, with a named home in tests/gates/ and a registry in
evals/gates/*.yaml that does not exist yet. Writing a partial one
into scripts/check_docs.py now would begin Milestone 0, pre-empt
where the check lives and what it reads, and create a second
authority on a question the corpus has already answered once.
Note: what the audit did, for whoever writes those gates. It
parsed every registry row out of the fenced tables and asserted
four things. Every identifier matches the grammar and its area is
one of the fourteen. No identifier repeats. Every well-formed
identifier appearing anywhere in docs/plan/*.md is a registry
entry. And the per-kind and per-spec counts in the harness's own
table, together with the per-milestone counts in the census, are
what the registry actually holds. All four pass now. The third is
the one that found the harness examples, and it is the one no gate
currently states.
Note: a malformed identifier written in prose is not an
identifier. milestone-map.md now quotes one deliberately, to show
what the grammar excludes, and says so where it quotes it, so gate
5 is not implemented as a scan of free text.
Reversal cost: none. This defers work rather than doing it.
The engineering plan owns two gates and has no hard-gates section¶
Decided: the spec field of gate.structure.import_boundary and
gate.structure.no_committed_secrets names the anchor of the
engineering plan's "Milestone 0: Repository and engineering
foundation" heading, and hard gate 4 is read as its own title says —
the anchor an entry names resolves — rather than as a literal test
for the string #hard-gates.
Why: hard gate 4 of milestone-map.md says each entry's
docs/plan/<file>.md#hard-gates anchor exists in the built site.
Fifteen documents carry that anchor and the built site confirms all
fifteen. engineering-plan.md is not one of them, and it owns two of
the hundred and seventy-two entries — the harness's own per-spec kind
table records them as a row reading "Engineering plan" with two
structural gates. Under the literal reading the gate fails on those
two the day it is written, and the failure has no fix inside the
registry.
Note: the anchor-agnostic reading is not an invention. Hard gate
4's title is "Every spec field resolves", and
evaluation-harness.md states the rule as "a gate whose spec
anchor does not resolve fails the docs check". Only the illustrative
form in the gate's body names #hard-gates, and rule 5 of the
milestone map introduced that form to correct two example entries
that pointed at #evaluation, which is a narrower claim than a
universal one.
Question for you: the alternative is to give engineering-plan.md
a ## Hard gates section holding those two, which makes the anchor
rule uniform at the cost of moving two requirements out of the
Milestone 0 acceptance criteria that state them. I recorded the
exception instead, because relocating a stated requirement is a
larger change than naming an exception to an anchor convention.
Reversal cost: low. One section and two spec fields.
Hard gate 7 has two readings and the strict one fails¶
Decided: hard gate 7 is asserted against the build-sequence table
in milestone-map.md, which is what its own sentence says, and not
against the per-gate step column.
Why: only one registry table of fifteen carries a step column,
the tool system's. Read per gate, the check fails on exactly two
rows: gate.tool.crash_recovery is Milestone 2 against step 4, and
gate.tool.dedup_concurrent is Milestone 2 against step 3, and both
of those steps are Milestone 1. Both placements are deliberate and
argued at length in the same document, under "Tool-system build
steps 3 and 4 without a database" and under decision 3, which says
a gate whose earlier form would be vacuous is registered at the later
milestone. Nothing recovers before there is persistence to recover
from, and a single-process dictionary cannot lose a race. A check
that failed on both would be relaxed in its first week.
Note: the reading the sentence gives does pass. I ran it over the
six specs of the build-sequence table and the three that tag their
own sequences: no spec has a gate at a milestone later than the last
step that builds it. gate.structure.migration_graph sits at
Milestone 0 against a Milestone 2 sequence, which is earlier and
therefore allowed, and the engineering plan says why.
Question for you: if the intent was the strict per-gate reading,
the two tool-system rows need something in the registry that records
the exception rather than leaving it in prose — an observes_step
field carrying a stated reason, on the optional field's precedent.
That is a registry schema change and I did not make one.
Reversal cost: low now, higher once the check is written against one reading.
Three of the map's own hard gates were run by hand and pass¶
Decided: recorded rather than implemented, on the same grounds as
the identifier audit before it. These are Milestone 0 gates with a
named home in tests/gates/.
Why: what was run, so that whoever writes them starts from a
known state. Gate 1, every top-level numbered item in a ## Hard
gates section carries exactly one trailing milestone token: fifteen
sections, one hundred and seventy-three items, one hundred and
seventy-three tokens, one per item, none with two and none with
none. Gate 3, a spec's gate count minus its declared alias count
equals the number of entries citing it: all fifteen agree, and the
three aliases are declared in the declaring spec with the owner named
and the owning gate's number given, in the same sentence form each
time. Gate 4, the anchors: fifteen documents carry #hard-gates and
the built site confirms every one, with the exception recorded above.
Note: the alias arithmetic is the one that moves the headline number if it is wrong, and it holds per spec rather than only in aggregate. One hundred and seventy-three items in hard-gates sections, minus three aliases, is one hundred and seventy rows across the fifteen tables, and the two the engineering plan declares outside any such section make one hundred and seventy-two.
Reversal cost: none. This is a record of a measurement.
The case table promised gates it does not carry¶
Decided: the heading now names what the table holds, and the gate-to-case direction is stated in prose rather than filled in with a column I would have had to invent.
Why: "The twenty-five cases, with milestones and gates" headed a
table whose columns are number, case, milestone, kind, and what only
this case proves. There is no gate column and there is no sign there
ever was one. The heading now reads "with milestones and kinds",
Kind being the column's own label rather than a word I chose.
Note: the arithmetic behind the heading is the larger finding.
Ninety-five of the hundred and seventy-two registered gates declare
kind case, which this document defines as a gate that runs as an
eval case, and the document enumerates thirty-one cases. Nothing in
the corpus reconciles the two numbers. Six cases are tied to a named
gate anywhere in prose — 19 and 26 to the two sandbox gates, 20 to the
policy specification's tenth by position rather than by identifier, 28
to the context engine's prefix gate, and 29 and 30 to the MCP set they
arrived with — and the other twenty-five are tied to nothing. So the
enumeration is the floor Section 20.3 asks for and not the size of the
finished suite, which the section now says.
Question for you: whether the binding belongs in the registry's
check field, which can carry it with no schema change, or in a gate
column on the table, which puts it where a reader looks for it and
duplicates a fact the registry owns. Recorded as the harness's seventh
open question rather than decided, because a column would mean
asserting which of the ninety-five case gates each row satisfies and
the corpus states that for six.
Reversal cost: the heading is free to change again. The binding is cheap to decide before the registry is written and awkward after.
Eleven cases are writable in Milestone 1, not ten¶
Decided: corrected the four live statements that said ten, and tied each to the range so the count is checkable.
Why: the case table places cases 1 through 11 at Milestone 1, and
development-toolchain.md, the
engineering plan, and the harness's own build order all say "cases 1
through 11". Three live statements said "ten of the twenty-five" and
one said a Milestone 1 checkout without the milestone field "fails
twenty of the twenty-five", where the figure is fourteen. The harness
contradicted itself: its build order and its Decision 15 disagreed
about the same set, four lines apart in the same document.
Note: each corrected statement now names the range as well as the count, so the number is checkable against the table rather than merely asserted. That is why the error survived: "ten" was a number with nothing to check it against.
Question for you: docs/adr/0022-evaluation-harness.md carries
the same stale figure in its eighteenth decision. It is left alone
under the rule that a decision record is a record at a point in time,
which I think is right, but an erratum note is the alternative and it
is your call rather than mine.
Reversal cost: none. The corrected numbers follow from the table.
The event catalog is fifty-three types, not fifty-one¶
Decided: added mcp.server.reauthenticated and
knowledge.document.ingested to the consolidated list in
runtime-loop.md and moved the total.
Why: "The event catalog is fifty-one types and is now closed" recorded its own cost — "the catalog is a second place to edit when an event is added" — and its own defence — "Stating the total makes the next addition visible: a spec that adds an event now has to move the count." The cost was paid twice and the defence did not fire. Git dates the two additions after the consolidation: tool-system.md gained the reauthentication event and knowledge-documents.md gained the ingest event, and neither moved the count.
Note: a third miss predates the consolidation rather than
following it. The tool system's events table has eight rows and the
consolidation took seven, so mcp.server.reauthenticated was in the
corpus and in range when the list was written. That is a transcription
error, not a maintenance one, and it is the argument for the check
being mechanical rather than editorial.
Question for you: none. The two are session-scoped, the ingest path is a tool and therefore runs inside a run, and the arithmetic is forced.
Reversal cost: none. The list is the union of what the corpus declares.
Four harness events have no session to be stored under¶
Decided: listed the four eval.* events apart from the
fifty-three rather than in with them, and recorded the storage
question as an open one in both documents that reach it.
Why: evaluation-harness.md
declares eval.suite.completed, eval.gate.failed,
eval.scenario.scored and eval.ceiling.hit "on the harness rather
than on the run", under a span root it says "is not agent.run". A
harness event has no session, and events.session_id is NOT NULL.
They are event types by every other measure and there is no row they
can occupy. Folding them into the catalog would have hidden that.
Note: the same wall is already named, once.
multi-device-and-surfaces.md
enumerates three ways out for device lifecycle events — nullable
session_id, a separate table, or a synthesized session — picks none,
and calls the second the smallest. Two documents arriving at the same
constraint independently is the finding here; only one of them noticed
it was a constraint. Each now points at the other.
Question for you: which way out, and it is one decision rather than two. My weak preference is the second, a separate append-only table for events that are real and belong to no session, because it leaves the event log's central invariant alone and because a synthesized session makes the word mean two things.
Reversal cost: a migration if the wrong table is built first, which is why it is worth answering before Milestone 3 rather than during it.
Three event names in live prose name nothing¶
Decided: replaced session.opened with session.created, and
tool.invoked and tool.completed with tool.call.started and
tool.call.completed.
Why: skills.md listed session.opened and
tool.invoked under the sentence "Three events carry skill
information, and none of them is new", which is the strongest
possible claim to be wrong about, and
bootstrap-and-composition.md
told the CLI to render tool activity from tool.invoked and
tool.completed. None of the three is in Section 6.8 or anywhere
else.
Note: this is the third instance of one defect. The harness
asserted tool.proposed, tool.authorized and tool.succeeded,
which was corrected under the recorded precedent that Section 6.8's
names are canonical, and runtime-loop.md
carries that as its thirteenth resolved conflict. Three documents
independently invented plausible short forms of the same four names,
which says the real names are longer than a writer reaching for them
expects.
Question for you: none. The substitutions are the nearest declared event to what each sentence meant.
Reversal cost: none.
The error taxonomy is twenty-nine classes, not thirty-one¶
Decided: corrected the count in runtime-loop.md and http-api-and-streaming.md, and deleted the two error classes the second invented.
Why: the runtime loop says "Eight more are raised by documents
written since and appear in no taxonomy" over a block of eight, two
of which — BudgetExceeded and ConflictError — are in Section 13's
twenty-three already. They are unclassified there, which is a real
finding, but they are not new classes. Six are. The union is
twenty-nine.
Note: the interesting part is downstream. The API spec inherited
thirty-one, has a table of twenty-seven, and closed the gap by writing
"and the two internal counterparts the loop resolves itself" — two
classes with no names, in no list, that exist only to make a
subtraction work. Set arithmetic over the real union gives exactly two
absent, WorkerFenced and EmptyModelTurn, and the same sentence
already named both. A wrong total propagated one document and then
manufactured evidence for itself in the next.
Question for you: whether BudgetExceeded and ConflictError
should get retry classifications in Section 13 itself rather than in
the runtime loop's additions block. The classifications are stated and
correct where they are; the objection is only that Section 13 is where
a reader looks.
Reversal cost: none. Both counts now follow from lists a reader can count.
The control-tool table listed a span as a tool¶
Decided: removed context.compact from the control-tool table
in tool-system.md, put
context.update_working_state in its place, and corrected the
lead-in that said "Three of the tool names" over four rows.
Why: nothing in the corpus declares context.compact as a tool.
runtime-loop.md uses it as a span nested
under the step span, the event compaction emits is
context.compacted, and
context-engine.md says compaction is a
model call and therefore "not something build() does" — the loop
measures pressure and invokes the compactor itself. A tool that let
the model force one would hand it a lever over its own context
budget that nothing in that document contemplates. What the context
engine does put behind a control tool is
context.update_working_state, which it declares in full: input
schema, effect, and the event context.working_state.updated.
Note: the set is still four, so skills.md
calling a hypothetical skill.unload "a fifth entry" and saying
"that table has four entries" are both still correct and both
untouched. The row was wrong, not the total.
Question for you: whether the model should ever be able to force a compaction. This says no, because the context engine says the loop decides and the model is the thing being budgeted. The case for yes is a model that knows it is about to do something long and would rather compact deliberately than be compacted mid-step. Recorded as the tool system's seventh open question.
Reversal cost: none. It would be a fifth row and a ToolSpec.
skill_manage was called a control tool in two more places¶
Decided: corrected both sentences in tool-system.md to match that document's own registration table.
Why: the file names skill_manage among the tools that motivate
a kind field at all, and states flatly that "skill_manage is a
control tool" in the bullet explaining that control tools still
pass the full pipeline. Its own table classifies skill.manage as a
capability tool, and skills.md argues at length
that it must be one, because it writes files and a control tool by
definition acts only on the run. The kind justification now names
context.update_working_state instead, and the pipeline bullet uses
skill.load, which is a control tool and carries the bullet's point
better: skills.md labels agent-authored skill content
EXTERNAL_UNTRUSTED, so exempting the category would exempt that
labelling from the step that applies it.
Note: an earlier pass corrected this document's registration table and the skills spec. These two sentences survived it, which is what a fix by search rather than by reading leaves behind.
Question for you: none beyond the standing one about spelling it
skill.manage in prose corpus-wide.
Reversal cost: none.
The builtin roster is eight tools of eighteen¶
Decided: added a subsection to builtin-tools.md naming the ten model-callable build-time tools that other specifications declare, and scoped the classification table and the registration steps to the eight the roster owns.
Why: the roster reads as the corpus's tool census and is not.
Ten more tools are model-callable at build time:
conversation.ask_user and delegate.run from
tool-system.md,
context.update_working_state from
context-engine.md, skill.load and
skill.manage from skills.md, three memory.
tools, and two knowledge. tools. The rule that makes the count of
eight correct was stated once, in
knowledge-documents.md: subject
specifications declare their own tools, "so this costs
builtin-tools.md nothing, and the roster's count is unchanged".
That sentence now lives where a reader of the roster will find it.
Note: the classification gap is recorded as a conflict rather
than closed. Registration step 6 reads ToolSpec.side_effect and
output_trust, and eight of the eighteen declare no ToolSpec
fields anywhere in the corpus. Step 3, domain membership, already
passes for all eighteen.
Question for you: whether the ten should be classified in
builtin-tools.md or in the documents that declare them. Recorded
as that document's ninth open question. One table is easier to check
against the registry; against that, a tool and its fields living in
different files is how the roster got read as the census in the
first place.
Reversal cost: low either way. The fields exist or they do not, and where they are written is a move rather than a redesign.
The agent-facing memory surface is three tools, not two¶
Decided: corrected readiness.md, which said the surface is two tools and that both of them read.
Why: memory.remember is a third and it writes.
memory-retrieval-and-ranking.md says two, correctly, because
within its own scope there are two: memory.search and
memory.recall_episodes. Formation declares the third. The
readiness note was reading a retrieval sentence as a corpus-wide
count.
Note: the argument the sentence supports is unaffected and is
kept as written. None of the three lists, edits, or deletes, and no
route or CLI command does either, so the MemoryStore.list, edit,
and delete gap stands exactly as stated.
Question for you: none.
Reversal cost: none.
The scope table was a row short of the route table¶
Decided: added | \GET /v1/sessions/{id}` | `session.read` |to the scope table in
[http-api-and-streaming.md](../plan/http-api-and-streaming.md), and a
paragraph naming what the scope gates, paralleling the one already
there forskill.write`.
Why: the document specifies fourteen routes in http fences and
its table had thirteen rows. Set comparison gives exactly one
difference in one direction and none in the other: the missing row is
GET /v1/sessions/{id}, which is the one route this document adds
rather than one it inherited. The consequence was that session.read
sat in the closed scope vocabulary, and in the nine the API document
enumerates, with no route requiring it — a scope nothing could check.
Note: the document's own hard gate 5 walks the route table and
fails the build on any route but the two health probes that declares
no scope. The document therefore declared a gate its own table would
fail. skill.write's absence from the same table is not this: it
has a paragraph saying it is checked by the policy engine on a tool
call rather than by the API on a route. session.read had no such
paragraph, which is what separates an omission from a seam.
Question for you: none.
Reversal cost: none.
Five sentences closed the API at thirteen routes¶
Decided: corrected them to fourteen, and made the API document state the resulting surface where a reader will find it.
Why: thirteen is the count of what the document inherited —
Section 16's nine, two approvals reads and one input route named
elsewhere, and the two health probes that Section 16 counts as one.
The document then adds GET /v1/sessions/{id}, which it argues for
at the route, records as resolution row 8, and decides as decision
20. Nothing stated the sum, so five downstream sentences quoted the
heading instead: readiness.md,
skills.md,
knowledge-documents.md three times,
and engineering-plan.md twice. The
engineering plan is the sharpest case: one line asks for an error
mapping for each of thirteen routes and the next sentence in the same
paragraph says one route is added.
Note: the overview also said the document "adds nothing to the API surface that Section 16 did not already put there" and that "every route below is a route the corpus already names". Both clauses are false, and the second is contradicted three times inside the same document. The heading is now "Thirteen inherited routes", the opening paragraph carries the sum, and decision 21 states it.
Question for you: none.
Reversal cost: none.
The next CLI noun was numbered fourteenth¶
Decided: corrected knowledge-documents.md to say a thirteenth CLI noun.
Why: the CLI is twelve commands. bootstrap-and-composition.md owns that census, prints the twelve in a table, and names the cost of adding one as "a thirteenth top-level noun". The sentence appears to have taken its ordinal from the route count in the clause before it.
Note: the CLI census is otherwise sound. The twelve commands, the
four reserved words after agent run, and the rule that a subcommand
under an existing command is not a new command all agree across
bootstrap-and-composition.md, evaluation-harness.md, and
event-log-and-persistence.md.
Question for you: none.
Reversal cost: none.
The run body stated two column counts and both were wrong¶
Decided: corrected both in http-api-and-streaming.md, and stated the split the body actually makes.
Why: the paragraph introducing GET /v1/runs/{run_id} said "The
runs table has fourteen columns and this returns nine of them".
Section 15 declares fifteen columns, four other documents add eleven
more, and the JSON body immediately below has thirteen top-level
keys. Neither number was derivable from anything in the corpus. The
overview of the same document says "the run record's twenty-three
columns", so the document also disagreed with itself; twenty-three
is exactly Section 13's error-class count, which this document
states correctly seventy-six lines later.
Note: the arithmetic that does close is thirteen returned and
thirteen withheld, and the split is the seam between Section 15 and
everything after it. Every Section 15 column is in the body except
lease_owner and lease_expires_at, and every column another
document adds is withheld — though deadline_at still reaches the
client inside limits, where it was a domain field before
runtime-loop.md gave it a column. The sessions section of the same
document is the control case: it states no count, is right, and
explains its one interesting omission.
Question for you: none.
Reversal cost: none.
The idempotency resolution was summarized as two tables¶
Decided: corrected readiness.md to say a table and a column.
Why: it said the API document resolved Idempotency-Key against
the tool idempotency port as "two scopes, two tables, two
milestones, one unfortunate name". The API document resolves it as
one table and one column: idempotency_keys for the HTTP header and
tool_invocations.idempotency_key for the tool call. Its own words
are "They share a name, a column name, and nothing else", and its
resolution row says "two mechanisms, two scopes" — not two tables.
Note: the rest of the sentence is right. Two scopes and two milestones are both correct, and so is the verdict that the name is unfortunate.
Question for you: none.
Reversal cost: none.
Two documents add the same column with one cross-reference¶
Decided: added the missing cross-reference to tool-system.md.
Why: policy-and-approvals.md and tool-system.md both add
origin_trust and idempotency_class to tool_invocations, both
NOT NULL, and both with a rationale paragraph. origin_trust had
a sentence naming the other document; idempotency_class, whose
rationale sits in the paragraph immediately below, did not. A reader
of either document alone would not know the column was declared
twice, and a migration author reading both would not know whether
that was intentional.
Note: the two declarations agree, so this is a documentation defect rather than a design conflict. The columns are denormalized onto the authorization record on purpose, which both documents say in their own words.
Question for you: none.
Reversal cost: none.
Milestone 9 and 10 persistence has no schema¶
Decided: left as is, and recorded.
Why: the corpus declares twenty-two tables as DDL across seven
documents, and every document that adds storage before Milestone 9
declares it in a fenced schema block.
memory-formation-and-consolidation.md and
knowledge-documents.md declare
their persistence as Pydantic models instead, and
multi-device-and-surfaces.md says the Device "gets a table
rather than a column" without giving the table any columns. So the
last two milestones are the only ones whose storage an implementer
cannot write a migration from.
Note: this may well be deliberate. The retrieval design those documents store for is the newest work in the corpus, and Section 29.8 defers the device schema explicitly. Inventing the columns would be exactly the kind of material addition this assignment is not allowed to make, which is why it is asked rather than decided.
Question for you: should the memory, knowledge, and device tables get DDL before coding starts, or is a Pydantic model enough to build Milestone 9 from?
Reversal cost: low. Adding a schema block to three documents touches nothing else, and none of the four gates that reference those documents reads a column name.
The schema is twenty-two tables and nothing says so¶
Decided: left uncounted, and recorded.
Why: the tables are declared across seven documents and no document enumerates them. The only statement of the size is event-log-and-persistence.md calling it "roughly twenty tables, two functions each", which is defensible for twenty-two and is hedged on purpose. Adding a census would mean choosing an owner for it, and the natural owner is Section 15, which this assignment does not edit.
Note: four of the twenty-two are effectively write-only in the
corpus. eval_scenario_runs and eval_criterion_scores are
mentioned twice each in all of docs/, and mcp_tool_catalog three
times, which is the declaration and little else. That is a
different problem from the count and is not fixed by fixing the
count.
Question for you: should one document own a table census, the
way bootstrap-and-composition.md owns the CLI census and
builtin-tools.md now owns the tool census?
Reversal cost: low.
The runtime port count came from the fences, not the ports¶
Decided: corrected runtime-loop.md to five, and named the sixth.
Why: the section headed "Ports declared here" opened "Four
ports the runtime needs are named in the corpus and declared
nowhere" and then declared five before the next heading: Clock,
IdFactory, AgentRepository, PrincipalResolver, and
BudgetLedger. Four is the number of code fences, not the number of
ports, because Clock and IdFactory share one. The same document
declares CancellationToken further down under cancellation, so it
declares six ports in total and five under that heading.
Note: the neighbouring count is right and was left alone. Line
29's "Section 7 declares eight port Protocols with full
signatures" checks against Section 7 exactly, and so does
multi-device-and-surfaces.md putting the device registry "beside
the seven repositories already there".
Question for you: none.
Reversal cost: none.
Eight ports had no module and a gate walks the directory¶
Decided: assigned all eight in
bootstrap-and-composition.md,
which meant adding ports/knowledge.py and ports/credentials.py.
Why: that document's "Where the ports live" table exists
because "without an assignment rule, the first implementer invents
one, and the second invents a different one", and it left eight
declared Protocols out of the table entirely: SkillRepository,
SkillPackageStore, Extractor, Chunker, KnowledgeStore,
WorkspaceHandle, ArtifactWriter, and CredentialResolver. That
is not a cosmetic gap.
evaluation-harness.md declares a
structural gate that walks agent_core/ports/, collects the
Protocols it finds, and fails the build for any without a contract
module, so where these eight live decides which contract suites
exist.
Note: six of the eight are the document's own rule applied
without judgement. SkillRepository goes with the repositories for
the reason multi-device-and-surfaces.md puts DeviceRegistry
there; SkillPackageStore and ArtifactWriter take bytes and hand
them back under a key, which is what artifacts.py is named for;
WorkspaceHandle is what ExecutionEnvironment returns. The table
now names thirty-nine ports across fourteen modules, and every
Protocol in the corpus that is not one of the four application
services of the API document has a home.
Question for you: the two new module names are the part that is
a choice rather than a derivation. ports/knowledge.py and
ports/credentials.py are what I used. The alternative for the
second was folding CredentialResolver into policies.py, which I
rejected because policy decides whether a tool may run and the
resolver hands it the secret afterwards, and into execution.py,
which I rejected because the sandbox spec's one structural claim
about it is that a sandboxed tool gets a resolver that raises.
Reversal cost: cheap now, moderate later. Renaming a module in prose is a search and replace; renaming it after the contract suites are written moves files and import lines in the gate that reads them.
Three rows in the ports table still name no type¶
Decided: left as prose rows, and recorded here.
Why: ports/telemetry.py is in the Section 5 tree and no
document in the corpus declares a telemetry Protocol.
memory-formation-and-consolidation.md declares MemoryStore and
MemoryConsolidator as prose bullets with method lists rather than
Protocol blocks, while
memory-retrieval-and-ranking.md
beside it declares five with full signatures. And no MCP port is
declared anywhere, though adapters.mcp's import row says it
depends on ports and domain, which may mean it implements Tool
and ToolRegistry and needs no port of its own. Writing the three
would be inventing interfaces, which this assignment does not do.
Note: the same harness gate is what makes this load-bearing rather than tidy. A port that exists as a sentence has neither a Protocol for the walk to find nor a contract module to demand, so the gate passes by not looking, which is the failure mode a structural gate is supposed to remove. The memory half is also the same asymmetry the schema audit found: the two newest specifications in the corpus are the two that describe their interfaces in prose.
Question for you: should the three be declared before Milestone 0 opens, or is the walk meant to find only what exists?
Reversal cost: low.
The type census found one silent re-declaration in thirteen¶
Decided: added the missing supersession paragraph under
ProviderReasoningItem in
model-gateway.md, and left the other
twelve duplicated declarations alone.
Why: the corpus declares one hundred and forty four distinct
types across one hundred and fifty nine declarations, so thirteen
types are declared more than once. Four of the thirteen have
identical member sets and cannot mislead anyone. Of the nine that
differ, eight already say that they differ: Run, ToolSpec and
MemoryRecord carry an "existing fields, unchanged" comment inside
the fence, ContextBudget, RunCheckpoint and ModelRequest are
introduced by a sentence naming what they extend, ModelProvider
has a supersession paragraph, and ModelCapabilities has a whole
section reconciling it row by row. ProviderReasoningItem had
nothing. The plan declares three fields, the gateway declares six,
opaque_payload becomes provider_payload, and no sentence in the
corpus says so.
Note: the rename is not cosmetic, because the field is
persisted. The plan's rules paragraph — store verbatim, never
summarize, drop on a provider switch — governs a field name that no
longer exists, and RunCheckpoint.conversation carries the item
into the checkpoint, so an implementer working from the plan and an
implementer working from the gateway write two different keys into
the same stored JSON. The string opaque_payload occurs exactly
once in the whole corpus, which is what made it invisible. The same
document solves this problem correctly twice in the same file, and
engages with the plan's block directly at the line above the
rename, which is why I read the third as an oversight and not a
style.
Question for you: the paragraph itself invents nothing and needs
no answer. The rename does, and it is now question nine in that
document's own list, beside question six, which asks the same thing
about tool_calling and vision. One answer covers both.
Reversal cost: none.
The ModelCapabilities reconciliation is not where the fence is¶
Decided: added a pointer sentence under the declaration.
Why: model-gateway.md reconciles its
ModelCapabilities against the plan's field by field, with a table,
a stated reason for every changed row, a precedence rule, and an
open question. That work sits three hundred and fifty six lines
below the fence that needs it. A reader who arrives at the
declaration, counts ten fields where the plan has eight, and does
not read on has no signal that the divergence was considered at
all.
Note: this is the general shape of what the type census found. Every reconciliation in the corpus is sound where it exists. What varies is whether the reader is standing where it is.
Question for you: none.
Reversal cost: none.
StopReason.STOP is not a member of StopReason¶
Decided: changed it to StopReason.END_TURN.
Why: runtime-loop.md names the empty
terminal turn by a stop reason that does not exist.
model-gateway.md declares seven members
and STOP is not among them. Of sixty five dotted enum member
references in the corpus this was the only dangling one, and the two
paragraphs immediately after it use MAX_TOKENS and CANCELLED
correctly, so the wrong one reads as authoritative.
Note: END_TURN rather than INCOMPLETE because the case being
described is a turn the provider says finished normally while carrying
neither text nor tool calls. INCOMPLETE is declared for a provider
that ended without finishing, which is a different anomaly, and the
gateway's ScriptedTurn already defaults its stop reason to
END_TURN. The paragraph is load-bearing: it is the trigger for the
empty-turn retry path and for EmptyModelTurn and
FailureReason.EMPTY_MODEL_TURN.
Question for you: none. Nothing else in the corpus reads STOP as
a value.
Reversal cost: one line.
SandboxMechanism had three values in the file that defines it¶
Decided: added fake to the annotation and to startup check 4.
Why: the enum is never declared anywhere, so the single site that
enumerates its values is authoritative by default, and that site is
bootstrap-and-composition.md's
Settings comment, which listed three. Six other places say four:
sandbox-isolation.md's mechanism table and its seventeenth
requirement, ADR-0029 item thirteen, engineering-plan.md, and this
file. The comment is what an implementer codes config.py from, and
sandbox: fake would have failed to parse.
Note: the second half matters more than the first. The seventeenth
requirement says fake is refused in production by the same check
that refuses docker, and startup check 4 asserted only sandbox !=
"docker". fake executes nothing and models the workspace as a
dictionary, so a production deployment configured with it would have
started, reported tool calls as run, and isolated less than the
fallback the check was written to refuse. The setting and its refusal
are both Milestone 1.
Question for you: none. Four sources agree and none of them is ambiguous.
Reversal cost: one comment and one clause.
The trajectory export used the tool vocabulary for a run outcome¶
Decided: changed the exported outcome from SUCCEEDED to
COMPLETED.
Why: the run-level terminal vocabulary is COMPLETED, FAILED,
CANCELLED, in RunStatus, in OutcomeKind lowercased, and in the
three places the corpus writes the terminal subset as a SQL tuple.
SUCCEEDED is the tool-invocation success spelling and exists nowhere
at the run level. The arity was already right; only the success word
was wrong.
Note: this one is persisted and externally consumed. The export
carries schema_version: 1 and ADR-0016 and ADR-0032 put it in front
of readers who are not us, so a consumer filtering on the vocabulary
the rest of the system uses would have matched nothing and reported no
successful runs. The word outcome occurs on two lines in the whole
corpus, which is what made it invisible. A sentence under the fence
now ties the field to RunStatus so the next reader does not have to
rediscover which of the two vocabularies it belongs to.
Question for you: none, unless you want the export to keep a vocabulary of its own, in which case the fix is to say so rather than to leave the divergence unremarked.
Reversal cost: one word and one sentence.
SuspensionKind is never declared and appears in two spellings¶
Decided: recorded, not edited.
Why: the type is annotated in Suspension and its members appear
only in trailing comments: APPROVAL | USER | CHILD_RUN in
runtime-loop.md and user_input | child_run on the suspended_kind
column in tool-system.md. No fence declares it. Unlike the three
above, this one is already visible:
multi-device-and-surfaces.md
names the divergence while proposing a hand-off as a fourth kind, and
readiness.md lists it among the identified gaps.
Note: declaring it now would settle the fourth-kind question by writing the enum, and that question is yours. The two spellings are the same three members, so nothing is ambiguous today except the casing convention, which the corpus otherwise resolves by uppercasing the stored status and lowercasing the domain value. Whoever answers the hand-off question should declare the enum in the same pass.
Question for you: the hand-off question you already have. This adds only that answering it should produce a declaration.
Reversal cost: none. Nothing was changed.
Milestone 1 implementation review¶
The in-memory tier stops at the five repositories the design names¶
Decided: the running loop keeps its checkpoint materialized for one inline execution, records checkpoint and model-attempt evidence as events, and does not add checkpoint or model-call repositories before their Milestone 2 schema. Run state and event writes are sequential rather than cross-repository atomic in this tier.
Why: bootstrap-and-composition.md names exactly five in-memory adapters and explicitly disclaims durability, recovery, and cross-repository transactions. Adding another store would make the demo look more durable while creating a persistence tier the plan never specifies.
Question for you: confirm that event-backed attempt identity and an execution-lifetime checkpoint are the intended Milestone 1 interpretation. ADR-0037 records the exact boundary.
Reversal cost: moderate before Milestone 2, expensive after its repository contracts and migration are committed.
The pre-policy pipeline permits only side-effect-free tools¶
Decided: the authorization stage allows SideEffectClass.NONE and returns
the fixed policy.milestone1.non_pure denial for every other classification.
Why: the tool pipeline is Milestone 1 work and the policy engine is Milestone 4 work. Treating every registered tool as authorized would make the pipeline unsafe as soon as an effectful builtin appeared; implementing a small shadow policy engine would create behavior that Milestone 4 must later unwind.
Question for you: confirm deny-by-default until the real policy port is wired.
Reversal cost: cheap while the only Milestone 1 builtins are read-only.
Plan-shaped model fixtures translate into the canonical domain type¶
Decided: the fixture loader accepts both the canonical FakeModelScript
serialization and the evaluation-harness examples using
kind: tool_call | final | error. The latter is translated at collection.
Why: the prose says fixtures serialize FakeModelScript, but its worked
YAML has a different, more concise shape than the domain declaration. Rejecting
the worked form makes the documentation false; changing the domain type to the
worked form weakens the provider-neutral model used by the runtime.
Question for you: choose whether the concise YAML is a supported authoring
format or whether the documentation should be rewritten to show canonical
ScriptedTurn fields. The implementation currently supports both.
Reversal cost: cheap; the eleven authored fixtures can be mechanically converted.
Eval loading is lazy and still uses the ordinary composition¶
Decided: normal CLI startup does not import agent_core.evals. Invoking
agent eval run loads the runner lazily; the runner asks the composition root
for deterministic adapters and drives the same RunService as agent run.
Why: the structural gate forbids production modules from reaching the eval package, while the harness requires the eval command to call normal application services and forbids a second loop. Lazy command loading satisfies both constraints without teaching the production composition about fixtures.
Question for you: none unless you prefer a separate agent-eval executable,
which would change the CLI spelling fixed by the plan.
Reversal cost: cheap.
Cross-process reads remain unavailable until persistence exists¶
Decided: agent session create, agent run get, and agent run events
exist over application services, but the identifiers printed by one process are
not visible to a later process in Milestone 1.
Why: the CLI table schedules these commands for Milestone 1, while the in-memory tier explicitly loses all state on process exit. File or SQLite storage would be an undeclared persistence system and would undercut the PostgreSQL milestone.
Question for you: confirm the command table means the service/command surface exists at Milestone 1, not that independent CLI invocations share state.
Reversal cost: cheap before Milestone 2; that milestone resolves the limitation without changing the commands.
Milestone 5 implementation review¶
HTTP idempotency is database-scoped, not globally keyed¶
Decided: changed the idempotency_keys primary key from the client string
alone to (tenant_id, principal_id, key).
Why: the detailed API design requires two tenants using the same string not to collide and requires the concurrent insert to wait within that scope. A global primary key makes the first property impossible even when every query contains the tenant predicate. ADR-0041 records why the behavioral requirement and hard gate take precedence over the leftover table-key sentence.
Question for you: confirm that the composite key is the intended correction to the detailed design's contradictory primary-key wording.
Reversal cost: high after clients depend on cross-tenant key reuse; it would also weaken a tested isolation property.
Static token identity uses four explicit environment variables¶
Decided: token mode requires AUTH_TENANT_ID, AUTH_PRINCIPAL_ID, and
AUTH_SCOPES, and accepts optional AUTH_ROLES. Development mode retains its
fixed local principal.
Why: the design requires the matching token to bind one configured principal but does not name the environment variables. Defaulting token mode to the development identity and every platform scope would make a missing deployment setting silently over-privileged.
Question for you: confirm these environment names before external operators script them.
Reversal cost: cheap before the first token-mode deployment, moderate after.
The live stream buffer holds 256 notifications¶
Decided: each SSE subscription has a 256-notification application buffer; overflow produces the specified unnumbered frame and closes the stream.
Why: the design requires a finite bound and deliberately declines to make a number part of the public contract. This value is large enough to absorb short replay handoffs while remaining small and predictable per connection.
Question for you: none unless deployment measurements justify making the bound configurable in a later milestone.
Reversal cost: cheap; reconnect semantics do not depend on the number.
Roadmap authorization and the Milestone 10 completion amendment (ADR-0061)¶
The owner made the substantive decisions live on 2026-08-20 — direction, the activation split, the four milestones and their order, the first channels, and full milestone treatment for operational hardening — so this section records only the mechanical choices made while writing them into the corpus.
The registry bound is one named constant¶
Decided: scripts/gate_registry.py now carries MAX_MILESTONE = 15, and
the entry bound, the census loop, and scripts/check_docs.py's project-state,
plan-heading, and current-milestone checks all read it.
Why: three literals (> 11, range(12), 0..10) had drifted apart
already — the docs check still bounded current_milestone at 10 while the
registry admitted 11 — and the next milestone would have needed four edits.
Question for you: none.
Reversal cost: cheap.
Zero census rows for authorized, unspecified milestones¶
Decided: the milestone map's census carries a 0 / 227 row for each of
Milestones 12 through 15 with the note "authorized, specification pending".
Why: the census check derives a row for every milestone up to the bound and compares it to the written table, and Decision 9 of the map already says a zero is reported rather than filled. The alternative — keeping the census loop at 12 until the first specification lands — would have made the bound lie.
Question for you: none.
Reversal cost: cheap; each row is replaced when its specification lands.
active_milestone stays 11 until Milestone 12's specification exists¶
Decided: project-state.yaml records Milestones 12 through 15 as
authorized with their design document paths under
documents_required_before_coding_reaches_them, and leaves active_milestone
at 11.
Why: "active" has meant the milestone whose implementation is in progress; Milestone 12 has no specification yet, and the plan's own rule is that the design document lands first. The authorization is recorded; activity is not claimed.
Question for you: flip active_milestone to 12 when
notifications-and-devices.md merges, or earlier if you want the state file to
lead.
Reversal cost: cheap.
Milestone 11 is listed as implementable from the corpus¶
Decided: readiness.implementable_from_corpus now includes 11.
Why: the readiness review's Milestone 11 verdict already says "no unnamed design choice between the corpus and the first red tests", which is the definition that list encodes; the omission was an oversight from when the scheduling design was new.
Question for you: none.
Reversal cost: cheap.
The plan's new milestone sections name their design documents without linking¶
Decided: Milestones 12 through 15 name notifications-and-devices.md,
subagents-and-delegation.md, inbound-surfaces.md, and
operational-hardening.md in code spans, not Markdown links.
Why: the strict MkDocs build rejects a link to a file that does not exist, and each document is the first deliverable of its milestone. The links are added in the pull request that adds the document.
Question for you: none.
Reversal cost: cheap.
Milestone 12 notifications and devices (ADR-0062)¶
The owner chose the milestone, the order, and the first transport. These are the design choices the specification made beneath that, each with its alternative.
NotificationService becomes two ports and the broadcaster stays¶
Decided: NotificationOutbox and PushTransport in two new port modules;
the name NotificationService is retired; LiveEventBroadcaster is unchanged.
Why: the seam audit's warning was that delivering to an open connection and to a device that has none are different durability problems; one façade would hide the split again.
Question for you: none unless you want the old name kept as an alias.
Reversal cost: cheap before implementation, moderate after.
Two gate areas, device and notify¶
Decided: six gate.device.* and fourteen gate.notify.*, one declaring
specification.
Why: device identity is the half Milestone 14's Surfaces reuse; giving it
its own census line keeps that reuse visible. The alternative is one notify
area of twenty, which is what browser did for profiles and grants.
Question for you: confirm or collapse to one area before code lands.
Reversal cost: cheap; it is a rename in the registry and the map.
Device lifecycle is audited as process events¶
Decided: device.registered, device.push_token_updated, device.revoked,
device.push_token_invalidated, device.deleted through the existing
process-event repository.
Why: a device has no session, and scheduling.md already took this way out
for schedules; a device_events table would only be worth it for a
principal-facing device-history route nobody has asked for.
Question for you: none.
Reversal cost: cheap.
Preferences are per-device muted_kinds¶
Decided: a JSON array column on the device row; no preferences table.
Why: the only preference anyone has named is "phone loud, laptop quiet".
Question for you: none.
Reversal cost: cheap; a preferences table is additive.
The dispatcher is a new notify role, not a maintenance sweep¶
Decided: agent worker --role notify with its own unit and environment
file holding only the database URL and the APNs settings.
Why: the maintenance role reads the environment file every role reads; the push key should live only where it is used.
Question for you: none.
Reversal cost: moderate; it is a systemd unit and a release-validation rule.
Scope names device.read, device.write, notification.read¶
Decided: resource-action pairs on the registry and inbox routes.
Why: ADR-0034 objected to a device. namespace for scopes granted to a
device; these govern routes, in the same coexistence browser.* tools and
browser.profile.* scopes already have. client.* is the alternative.
Question for you: confirm the names before the scope vocabulary grows.
Reversal cost: cheap before code, moderate after.
Email stays a later transport; run.completed stays out of the set¶
Decided: APNs only; no interactive completion kind; capabilities and
granted_scopes columns deferred; lock-screen approve/deny out of scope.
Why: each is additive on the ports and models this milestone lands, and each is on the roadmap with its own entry condition.
Question for you: none.
Reversal cost: cheap.
Milestone 13 subagents and delegation (ADR-0063)¶
The objective is a structured brief¶
Decided: objective, success condition, optional context and artifact references, an explicit allowed-tool subset, optional limits, and a return shape; validated by the ordinary schema validator before policy.
Why: the child seeds from the brief and nothing else, so the stop rule has to travel with it; this closes the "carrier and no schema" partial.
Question for you: confirm the field list before code; a plain string is the alternative and is strictly less.
Reversal cost: cheap before implementation, moderate after.
A dedicated child session always¶
Decided: the Section 27.6 "or the parent's session per policy" branch is deleted (ADR-0061, decision 7); the one-active-run index is untouched.
Why: only this branch is implementable as the schema stands, and the index predicate is what keeps one active run per session true.
Question for you: none.
Reversal cost: expensive after; it is a schema invariant.
Starting caps: three per call, eight per parent, depth one, async priority¶
Decided: as stated, from the versioned limits file.
Why: runaway cost is the primary risk and the capability scenario will show whether the numbers are too tight.
Question for you: whether children of an interactive parent may take interactive priority; the conservative start is no.
Reversal cost: cheap; configuration.
Activation needs the owner's failed trajectory¶
Decided: construction is authorized; tenant activation waits for a capability scenario admitted from a real redacted failed long-research trajectory and for case 32.
Why: the plan's gate names evidence; the evidence is cheap once the tool exists and impossible before.
Question for you: supply the trajectory, or accept that the milestone is buildable but not activatable until one exists.
Reversal cost: none; it is the plan's rule.
One gate area, delegate¶
Decided: twenty-one gates in one area.
Why: delegation is one story; nothing in it is reused by a later milestone the way device identity is.
Question for you: none.
Reversal cost: cheap.
Milestone 14 inbound surfaces and pairing (ADR-0064)¶
Long polling rather than a webhook¶
Decided: the surface role polls getUpdates, holds a per-surface advisory
lock, and resumes from the last committed update; no public route is added.
Why: the API is loopback-only behind Nginx and a webhook would couple inbound delivery to API availability for no benefit at this scale.
Question for you: none unless the deployment gains a second host.
Reversal cost: cheap; a webhook is a second implementation of the port.
A paired sender's message is USER for the bound principal¶
Decided: pairing is authentication; the owner's own pairing yields USER;
pairing anyone else to the owner's principal needs explicit approval and is
bounded by granted_scopes; no new trust level.
Why: the seam audit's caution is about third parties, and this milestone has none; adding a label with no sender to carry it would be speculative.
Question for you: confirm before pairing any non-owner sender.
Reversal cost: moderate; a label is a closed-vocabulary change.
Session rotation: /new, twenty-four hours idle, closed session, stale agent version¶
Decided: as stated; a rotated key is never reused.
Why: the seam audit asked what happens when a thread outlives an
agent_version; rotation on idle plus drift answers it without surprising a
mid-conversation sender.
Question for you: the idle period.
Reversal cost: cheap; configuration.
Approvals by text command, not inline keyboard¶
Decided: /approve <id> and /deny <id> through the existing approval
service; a plain reply answers a waiting question.
Why: same authority, no second resolution entry point.
Question for you: none.
Reversal cost: cheap; additive.
Attribution on the write, not on a column¶
Decided: origin on the seed message and the queued run, the receipt as the reverse map, the surface in session metadata.
Why: a session may be continued from the Apple client; it is not owned by a channel.
Question for you: none.
Reversal cost: cheap; a column is additive.
Milestone 15 operational hardening (ADR-0065)¶
Object storage plus provider snapshots, age with an owner-held identity¶
Decided: daily encrypted backups to S3-compatible object storage in another
region via rclone, provider weekly snapshots as a complement, age X25519
with the private identity only off-host.
Why: snapshots alone are crash-consistent, include secrets under the provider's keys, and cannot restore one database; the host not being able to read its own backups is the point.
Question for you: whether a root-only copy of the identity should also live on the host to allow nightly off-host rehearsals.
Reversal cost: cheap.
Secrets are escrowed manually, not backed up¶
Decided: environment files, the browser keyring, and certificates are
excluded from the automated set; make secret-escrow and a staleness signal.
Why: a bucket compromise must not be a live-credential compromise.
Question for you: none.
Reversal cost: cheap.
Alerts through the outbox, dead states through a free external pair¶
Decided: ops_alert via the Milestone 12 outbox for degraded states; an
external uptime check and a dead-man's switch for the database, disk, or host
being down; no OTLP exporter.
Why: the outbox is durable and already reaches the phone; nothing that depends on the database can report the database being down.
Question for you: the uptime and dead-man providers; whether to add Telegram routing once Milestone 14 exists.
Reversal cost: cheap.
Rollback is code-only; migrations are forward-only¶
Decided: as stated; a pre-migration dump in the release path.
Why: the persistence design already says a schema rollback is a restore.
Question for you: none.
Reversal cost: none; it is the standing rule.
The backup tranche could lead¶
Decided: recorded as an open question, not reordered; Milestone 15 follows 14 as the owner chose.
Why: the tranche has no dependency on Milestones 12 through 14 and the risk is live.
Question for you: pull the backup and restore slice ahead, or keep the order.
Reversal cost: cheap; it is sequencing.
Milestone 16 memory evaluation and lifecycle (ADR-0069)¶
The yardstick lands before anything it measures moves¶
Decided: the benchmark corpus, its metrics, and a checked-in baseline ship first; every later change in the milestone re-records that baseline in its own change, and the exactness gate makes that mandatory.
Why: a benchmark edited alongside the behavior it scores measures nothing, and an exact comparison is available because the arm is deterministic.
Question for you: none.
Reversal cost: cheap; a tolerance could be added, at the cost of the property that makes the number trustworthy.
No model judges an answer¶
Decided: checked-in normalized labels, token-bounded matching against gold values, and one exact abstention phrase; token F1 only inside the external dataset adapters, because their published metrics use it.
Why: a judge makes the yardstick move on its own and puts a provider call inside the definition of "better".
Question for you: whether a judged arm is ever worth adding as a second, clearly-labeled number for comparison against published LongMemEval figures.
Reversal cost: moderate; a judged arm would need its own artifact and its own ceiling.
The live arm's ceiling is USD 4.00 per invocation¶
Decided: a pre-admission check that refuses the run which would cross the ceiling, plus an abort when the model's catalog price is zero and the ceiling would be unenforceable.
Why: roughly sixty-four probes run twice each is the shape of the arm, and a post-hoc check on a paid loop is not a ceiling.
Question for you: the ceiling itself, and whether the twenty-per-cent lift floor is right before there is live evidence to calibrate it.
Reversal cost: cheap; both are constants, and changing the lift floor re-publishes evidence rather than re-recording the baseline.
Public datasets are loaded from a local path, never vendored¶
Decided: LongMemEval (MIT), LoCoMo (CC BY-NC 4.0), and HaluMem (CC BY-NC-ND 4.0) are read from a path the operator supplies; only derived metrics leave the run, and CI tests the adapters on synthetic fixtures.
Why: two of the three licenses forbid vendoring outright, and local non-commercial evaluation is what the terms permit.
Question for you: whether the derived metrics should ever be published outside your own machine.
Reversal cost: none for the mechanism; publishing would need a licence review.
Conflicts are surfaced rather than resolved¶
Decided: an inference contradicting a user-stated belief, or a contradiction between equally authoritative statements that nothing orders in time, commits flagged and linked in both directions, requests confirmation, and surfaces both statements in recall. Authority first, then recency.
Why: the retrieval design already says unresolved conflicts are surfaced and not silently resolved; today a contradiction always supersedes, which lets an inference overwrite something you said.
Question for you: none, beyond noticing that recall noise rises by the conflict pairs, which is recorded deliberately in the baseline.
Reversal cost: cheap; the resolver's fourth outcome is additive.
Forgetting is a bounded sweep, and usage never raises confidence¶
Decided: decay lowers confidence on provisional or low-confidence beliefs idle beyond a per-type half-life and retires those below a floor; citing a belief resets decay and raises utility and never confidence.
Why: both restate standing decisions in the memory specifications; the second exists so a wrong belief that ranks well cannot entrench itself by being retrieved.
Question for you: the per-type half-lives and the near-duplicate similarity threshold, which ship as hand-set defaults and become tunable once the benchmark can measure the effect of tuning them.
Reversal cost: cheap; they are profile knobs, and moving one re-records the baseline.
Re-derivation stays opt-in and demands a confirmation¶
Decided: agent memory replay --session <id> --confirm reprocesses one
named session and replays outstanding rejections.
Why: the formation design makes re-derivation opt-in per principal, and silently re-mining old episodes is exactly the surprise that rule prevents.
Question for you: none.
Reversal cost: none; it is the standing rule.
ADR-0068 built this surface, and Milestone 16 verified it against the standing
rule as gate.memory.rederive_opt_in; there is no all-session mode or second
command.
Milestone 16 implementation review¶
The milestone completed on 2026-08-23. These are the decisions its final whole-branch review escalated, in the standing format, plus the deferred follow-up list. None blocks anything; every item is open for a different answer.
Replayed older evidence conflicts instead of superseding¶
Decided: the conflict resolver's cross-session recency test anchors on the
incoming candidate's newest source-event time compared against the existing
belief's valid_from, so agent memory replay --confirm of an older session
produces a flagged conflict pair rather than superseding a newer belief with
older evidence. Before this fix the anchor was the consolidation instant, and a
replay demonstrably reverted currency.
Why: the re-derivation surface must never make the store less current, and
updated_at stopped being an evidence signal once usage feedback and decay
began touching it.
Question for you: should older replayed evidence instead be ignored as stale — no new record at all — rather than surfacing as a conflict?
Reversal cost: cheap; one resolver branch and the property gate's oracle.
The whole-row reinforce write keeps its narrow lost-update window¶
Decided: utility moves and decay reuse the existing whole-row reinforce
write, so a sweep that read a belief before a concurrent supersession commits
can write the old status back. This is the adapter's pre-existing pattern
(edit and reject share it), not something Milestone 16 introduced, and the
sweeps are lease-guarded.
Why: column-scoped updates or compare-and-set on updated_at are a
persistence-layer hardening outside the milestone's scope.
Question for you: schedule the column-scoped or compare-and-set hardening?
Reversal cost: none; the change is additive.
A deleted snapshot member gets no correction line¶
Decided: correction lines cover superseded, expired, and retired snapshot members — the three statuses the design names. Deletion removes the row, so a frozen prefix keeps rendering a deleted belief with no correction.
Why: deletion is a rare, operator-driven act the correction design never mentions, and inventing a tombstone mid-milestone would have been new design.
Question for you: should deletion write a tombstone the correction pass can render, or is the next snapshot rebuild soon enough?
Reversal cost: moderate; it needs a tombstone or soft delete and a migration.
Live benchmark evidence carries the model's answer text¶
Decided: the published live artifact records each probe's one-line model answer verbatim. Over the synthetic corpus that text restates invented facts about invented people, and both READMEs disclose it.
Why: the answers are the evidence that scoring was honest, and nothing in the corpus is real.
Question for you: before any non-synthetic corpus exists, should a schema bump hash or redact the answer rows?
Reversal cost: cheap prospectively; published artifacts are immutable.
The live-arm retry hardening shipped without its own ADR¶
Decided: after two runs failed only the absolute incomplete-runs condition, the arm gained a content-free terminal failure class and one retry per probe arm — excluding terminations the runtime decided on — and the third run published. The design document and the capability README record the mechanism; no ADR was written.
Why: the published pass conditions did not change, so this read as harness repair rather than an architectural decision.
Question for you: does the retry semantics deserve an ADR-0069 amendment?
Reversal cost: none; it is documentation either way.
The deferred follow-up list¶
Decided: the per-task reviews and the final whole-branch review left these as accepted, recorded limitations rather than blockers.
- Benchmark: corpus v2 should add a lower-authority conflict scenario, since conflict surfacing is proven by a gate but not yet measured by the benchmark; citation-based usage feedback is structurally inert under the harness's sequential identifiers.
- Live arm: the four first-attempt failures were the without-memory arm of the
four multi-hop probes at their second position — content-correlated and
undiagnosed; a retried arm's first failure class is discarded, so an
additive
first_attempt_failure_classis the next instrument; an arm that fails outside the run records zero cost. - Retrieval: an uncited belief's negative utility does not lower its rank; a superseded row is not stale-penalized in historical queries; the recall delta yields entirely to the base block when the base fills the shared token class; a conflict pair ranked below a full subject cap drops whole; a rendered conflict marker may name a partner the budget dropped.
- Formation: the fact-subject fallback can produce verb-bearing subjects that never pair with their contradictions; one malformed fact discards the whole working-state snapshot; fact statements are not grounding-checked against the cited source text; replaying a later reinforcing session re-reinforces.
- Events and configuration: the
memory.citedevent is absent from the runtime-loop event catalog and stamps an actor the recall event does not; seven profile knobs (semantic_enabledand the async and child snapshot blocks) are carried by the specification with no consumer yet. - Persistence lanes: no PostgreSQL test exercises decay and retirement end-to-end; a nightly deterministic lane remains an open question.
Question for you: pick any to schedule; none blocks the next milestone.
Reversal cost: not applicable; this is a list, not a decision.
Milestone 17 memory read API and browser (ADR-0070)¶
A second parallel workstream, and it moves no ceiling¶
Decided: Milestone 17 develops alongside Milestones 13 through 15 and alongside Milestone 16, on the clause ADR-0069 decision 1 already established: its gates may go green independently, and the verified ceiling still advances only in numerical order, so nothing here moves it past 15. You authorized this when you authorized the milestone.
Why: the work shares no file with the delegation, surface, or operations tranches, and it reads a belief store Milestone 16 has finished maturing.
Question for you: none about the mechanism. The only judgment worth revisiting is how many parallel memory workstreams you want in flight at once, now that this is the second.
Reversal cost: cheap; it is sequencing.
Round one reads and never writes¶
Decided: browse, search, filter, and detail. No edit, no retraction, no
deletion, no confirmation of a flagged conflict over HTTP; corrections stay on
the agent memory command line, which routes them through the governed
formation service rather than around it. You fixed this boundary when
authorizing the milestone, and a structural gate walks the router so a write
route cannot arrive by accident.
Why: reading is the half people asked for and the half that pays for itself immediately; writing raises provenance, tombstone, and formation-watermark questions that reading does not, and none of them blocks the surface.
Question for you: whether the first write to arrive later should be single-belief retraction — the correction people reach for most — or whether corrections should stay on the command line indefinitely.
Reversal cost: cheap as scoping; a write route is additive. Expensive as design, because it needs the three answers above before it can be specified.
The native client browses at the restricted ceiling¶
Decided: full-parity viewing. You considered the conservative alternative, a lower ceiling for the graphical surface, and rejected it, accepting the consequence explicitly: a stolen, unlocked device holding a live token can read restricted beliefs. ADR-0070 decision 5 records that acceptance rather than burying it.
Why: a browser that silently omits rows teaches its reader that the platform forgot something it did not forget, and an invisible omission is worse than no browser at all. The device is yours, the token is device-local and keychain-held, and the surface is private in the same sense the Milestone 9 runtime surface already is.
Question for you: whether opening the browser should additionally demand a biometric or passcode re-authentication. That would mitigate the accepted consequence, but it is a new layer in ADR-0017's stack and needs its own decision rather than arriving as a client detail.
Reversal cost: cheap; the client supplies the ceiling as a parameter like any other caller, so lowering it is a one-line client change and no server change at all.
The ceiling is required, and the server never supplies one¶
Decided: every request carries an explicit ceiling; a request that omits
it is a validation error, not a request served at the permissive end. This is
ADR-0045 decision 11's rule restated for a route, and gate 1 exists so that a
default cannot be reintroduced quietly the first time a client forgets the
parameter.
Why: a default ceiling is the failure mode where a caller added next year is trusted by omission, and the mapping from scopes or roles to a sensitivity ceiling is wrong the first time a surface exists that its author did not imagine.
Question for you: none.
Reversal cost: cheap as code and expensive as a decision — softening it would weaken a stated security requirement, which needs your explicit approval rather than a reviewer's.
Three provenance fields were withheld, and this one is now decided¶
Originally decided (superseded below): MemoryView exposes twenty-three
fields from an explicit allow-list. tenant_id, principal_id, utility,
and store_position are withheld outright and that is settled.
formation_run_id, consolidation_policy_version, and origin_scopes are
withheld as a recommendation flagged for your sign-off, not as a decision
the specification makes on its own.
Why: the trio is genuine provenance and you could reasonably want it in a
belief's detail view. It is also the operator-tier vocabulary that the
formations audit surface owns, and putting three of its fields in a belief
response answers a question this milestone deliberately deferred — a reader
who sees a policy version and a formation run identifier will next want the
run, and there is no route to it.
Question for you: expose the trio now, or withhold it and revisit when a consolidations view exists, so provenance arrives as one coherent surface? This is the one item in this section that the implementing change should not land without an answer to.
Reversal cost: cheap before the code ships. Moderate after: adding a field to a response is additive, but removing one a client has started reading is a breaking change on a surface the native client consumes.
Decided: exposed, 2026-08-23. You answered the question above during
review of the implementing change: formation_run_id,
consolidation_policy_version, and origin_scopes join the exposure list, so
MemoryView now exposes twenty-six fields rather than twenty-three.
tenant_id, principal_id, utility, and store_position remain withheld
outright, unchanged from above.
The default page is the live set, not the whole history¶
Decided: with no status parameter the list returns active and
provisional beliefs. Superseded, expired, and retired rows are one parameter
away.
Why: a browser that opens onto history rather than belief answers a different question from the one its user asked, and the retired rows outnumber the live ones as soon as the decay sweep has run for a while.
Question for you: whether "what did you used to believe about me" is common enough that the first page should show everything and let the reader narrow, which is the opposite default.
Reversal cost: cheap; it is a default, and both behaviors are reachable today.
Trace viewing is deferred on a defect, not on preference¶
Decided: no recall-trace route in this milestone.
PostgresTraceStore.for_turn selects traces by turn identifier alone, with no
tenant or principal predicate, so it is not safe behind a route at any ceiling.
That predicate must be added and observed by the trace store's contract suite
before a trace route can be designed. The specification names the defect rather
than describing the deferral as a scoping preference.
Why: naming it is what stops a trace route being added casually later by someone who assumes the store method is already principal-scoped. A trace view would also owe the minimum-of-two-ceilings rule the retrieval design requires, which is a second ceiling mechanism inside a milestone whose whole ceiling story is one parameter.
Question for you: fix the for_turn predicate inside Milestone 17's
implementation change, where it is a few lines and a contract-suite case, or
leave it to the milestone that eventually designs the trace route?
Reversal cost: none for the deferral. The fix itself is small, and leaving it unfixed costs nothing until something tries to reach a trace.
Milestone status review of 2026-09-28¶
Six milestones recorded complete from their pull requests' records¶
Decided: Milestones 13, 19, 20, 22, 23, and 29 are recorded complete. Each
had only "hosted CI and the CodeRabbit review loop" left open, and each had
already passed both on the dev to main pull request that carried it: pull
request 72 for Milestone 13, 64 and 66 for 19, 74 for 20, 82 for 22, 89 and 98
for 23, and 118 and 127 for 29. On every final head all hosted lanes passed,
CodeRabbit completed its review, and every conversation is resolved; you merged
each one. Pull request 64's merge static job failed; pull request 65 first
deployed its changes. The other listed merges' deploy jobs succeeded. The evidence moved to
verification-history.yaml. Completing Milestone 13 advances the verified
ceiling to Milestone 13 and 268 gates; agent eval gates --milestone 13 passes
every gate except the seven sandbox gates, which need Docker and pass in the
hosted sandbox lane.
Why: the state file had not been updated since those merges, so it showed finished work as open.
Question for you: none, unless you want a milestone kept open for a reason the state file never recorded.
Reversal cost: cheap; a status and its evidence move back.
Two pull requests merged with CodeRabbit findings unresolved¶
Decided: Milestones 14, 18 and 25 keep their review items open and now name the findings. Pull request 104 merged on 2026-09-11 with sixteen unresolved findings on the Telegram and WhatsApp surface code, including an authorization-bypass finding and three sensitive-data-exposure findings. Pull request 94 merged forty seconds after CodeRabbit posted one finding on the multi-account Gmail bootstrap, and that defect is still in the code.
Why: AGENTS.md makes a resolved conversation part of the merge gate, so these loops are not complete.
Question for you: fix the seventeen findings in one dev to main pull request, or answer the ones you judge inapplicable on the original threads?
Reversal cost: none; the items only describe open work.
Milestone 31's flag went on before its smoke¶
Decided: the item "Production activation of AGENT_EMAIL_UNSUBSCRIBE_ENABLED after the smoke" is closed as done, because the flag was already on in production by 2026-09-21. The owner-authorized real-mailbox smoke stays open as the milestone's acceptance evidence for the live mailboxes.
Question for you: keep the flag on while the smoke is outstanding, or turn it off until the smoke passes?
Reversal cost: cheap; one environment line and a restart.
Eight implemented ADRs are still Proposed¶
Decided: recorded as an open item rather than changed. ADR-0104, ADR-0105,
ADR-0106, ADR-0107, ADR-0114, ADR-0135, ADR-0137 and ADR-0138 each say
Proposed, although their mechanisms are implemented, described in the detailed
designs, and on main.
Question for you: accept or reject each one.
Reversal cost: cheap for an acceptance; a rejection means reverting the mechanism or writing a replacement ADR.
The .git/config hardline blocks only the workspace root¶
Decided: recorded as an owner decision outside the milestones, not fixed.
The hardline protected_host_path rule blocks .git/config and
./.git/config, but not sub/.git/config, while .env and ~/.ssh already
match at any depth.
Why: the rule is a security boundary, and the obvious repair, an edit to
hardline.yaml, moves policy_version and unbinds the memory-formation
release evidence.
Question for you: should the rule match .git/config at any depth? If so,
a change to the matcher in policy/hardline.py achieves it without moving the
policy version.
Reversal cost: cheap in code; expensive if done through the YAML, because the memory-formation evidence would have to be regenerated.