Source and verification
This page covers Project source in the git service, the Sandbox, checks and Evidence, sibling selection, and promotion.
Main source files:
workers/integration/src/provision.ts,canonical-source.ts,code-store.ts,repo-names.ts,project-cleanup.tsworkers/agents/src/workspace.ts,stack.tsworkers/integration/src/verification-workflow.ts,candidate-sandbox.ts,browser-check.ts,runner-selection.tsworkers/integration/src/candidate-create.ts(fork rule),integrate.ts,acceptance-check.ts,candidate-selection.ts,promotion.tsworkers/apps/src/checker.ts,checker-program.ts(checks in the apps service)services/git,services/sandboxd: the git service and the Sandbox hostpackages/evidence/src:plan.ts,gate.ts,promotion.ts,compatibility.tsworkers/coordinator/src/promotion-cas.ts,project-root-do.ts
Git repositories
Section titled “Git repositories”The git service holds the Project source (ADR 0010, services/git).
Each repository is a bare Git repository on the git-data volume. The one namespace is rhumbatron (GIT_NAMESPACE).
Core reaches the control API through the ARTIFACTS binding (services/git/client.ts). Git traffic uses smart HTTP.
A fork is a bare clone that borrows the objects of its source through objects/info/alternates and keeps its own main.
flowchart TB
subgraph NS["git service, namespace rhumbatron"]
CAN[("proj_uuid<br/>canonical repo, branch main")]
WS1[("ws-run<br/>Task workspace fork")]
WS2[("ws-run<br/>Task workspace fork")]
CA[("cand-id<br/>Candidate fork")]
end
PROV["project.provision<br/>(integration)"] -->|"create, commit .gitignore"| CAN
AG["AgentDO run"] -->|"fork on the first<br/>workspace tool call"| WS1
AG --> WS2
VW["CandidateVerificationWorkflow"] -->|"fork, merge ChangeSets"| CA
CA -->|"fast-forward main<br/>after the CAS"| CAN
SWEEP["forks.sweep, daily"] -.->|"delete 30 days<br/>after the end"| WS1
SWEEP -.-> CA
IDX["indexer"] -->|"read token"| CAN
Repository names come from database row IDs only (repo-names.ts):
| Repository | Name |
|---|---|
| Canonical | The Project ID, proj_<uuid> |
| Task workspace fork | ws-<run> for Task run run_<run> |
| Candidate fork | cand-<id> for Candidate cand_<id> |
| Code edit session fork | edit-<hex> for session edit_<hex> (ADR 0029) |
Rules:
- Cleanup does not list the namespace. It deletes only the names that it derives from the rows of one Project. Thus it cannot delete a repository of a different Project.
- Each Git access uses a repository token that expires after 600 s.
- The token is an HMAC signature over the token ID, the repository, the scope (
readorwrite), and the expiry. It grants one repository. The code revokes the token after use. - The daily
forks.sweepcommand deletes a fork 30 days after its run or Candidate ends (FORK_RETENTION_DAYS). - The sweep deletes the workspace forks of failed runs, of runs without a ChangeSet, and of finished Changes.
- The sweep deletes the forks of
rejected,stale, andpromotedCandidates. It deletes a promoted fork only when canonicalmainholds the composed commit. - A code edit session deletes its fork when it ends. The daily
forks.sweepcommand ends overdue sessions and deletes a session fork that a failed delete left. - Each Project starts at Canonical Generation 0 (
S0) with one commit that holds only.gitignore(ADR 0022).
Source objects in the object store (bucket rhumbatron-evidence, binding EVIDENCE):
| Object | Key | Writer |
|---|---|---|
| File index | source/<project>/<tree>/index.json |
Provisioning and each promotion |
| Code manifest (path to Git blob ID) | source/<project>/<tree>/code.json |
Provisioning and each promotion. Composition also writes the Candidate tree when core has the APPS binding. |
| Blob | source/<project>/blobs/<blob> |
Same as the code manifest. Each blob is copied once. The manifest lists a file over 1 MB, but the store keeps no copy of it. |
| ChangeSet patch | source/<project>/patches/changesets/<cs>.patch |
Agent publish (patchRef) |
| Candidate patch | source/<project>/patches/candidates/<cand>.patch |
Candidate composition |
- Agents read the file index. They do not walk the repository.
- A patch stops at 512 KB, at a line boundary. The object metadata records
truncatedand the full size. - Nothing expires
source/. Project deletion removes it (see cleanup.md).
Composition and promotion in Git terms
Section titled “Composition and promotion in Git terms”A Candidate fork starts at the canonical base commit.
The CandidateVerificationWorkflow merges the ChangeSets in Work Graph order.
When the composed package.json has a build script, composition runs bun install and bun run build. It then commits the files that the build changed (Build <candidate>). See Live apps.
Promotion moves canonical main to the composed commit. The move is fast-forward only.
gitGraph commit id: "S0 base" branch ws-t1 checkout ws-t1 commit id: "t1" checkout main branch ws-t2 checkout ws-t2 merge ws-t1 id: "stack t1" commit id: "t2" checkout main branch cand-a checkout cand-a merge ws-t1 id: "compose t1" merge ws-t2 id: "compose t2" checkout main merge cand-a id: "S1 promoted" tag: "S1"
Notes:
gitGraphdraws the last step as a merge. The code pushes the composed commit as a fast-forward (canonical-source.ts). It never does a force push.- The push runs in core with
isomorphic-giton an in-memory file system. It uses no Sandbox time. - When the push fails, the compare-and-swap result stays. A retry of the promotion pushes again.
Sandbox lifecycle
Section titled “Sandbox lifecycle”sandboxd runs each Sandbox as a sibling container through a socket proxy (services/sandboxd).
Each Project has one Sandbox, with the ID proj-<uuid>.
Agent runs and Candidate checks use different directories in it.
At most SANDBOX_MAX (4) Sandboxes are live at one time. A call that needs one more fails with ContainerUnavailableError.
Each Sandbox has 2 CPUs, 2 GiB of memory, no swap, and 512 processes. The container runtime is SANDBOX_RUNTIME: runc by default, and runsc (gVisor) on a Linux server.
A Sandbox sits on the internal network rhumbatron_sbx. It reaches the git service directly and package registries through the egress proxy allowlist. It has no other route out.
Only Task kinds implement, migrate, and test open a workspace.
sequenceDiagram
autonumber
participant A as AgentDO
participant DB as Product database
participant Art as git service
participant SBX as Sandbox proj-id
A->>DB: admitSandbox: fewer than 4 open workspaces in the Change?
A->>DB: reserve 300 s on the monthly meter
DB-->>A: refuse when the cap would be exceeded
A->>Art: fork canonical as ws-run
A->>SBX: git clone into /workspace/run (read token)
A->>SBX: check out base, merge stacked ChangeSets, bun install
loop agent tools
A->>SBX: read_file, edit_file, write_file, run_command (allowlist, 180 s)
end
A->>Art: write token (600 s)
A->>SBX: git commit, push to ws-run
A->>Art: revoke token
A->>DB: settle: awake time this session adds to the container (overlaps once, 120 s tail)
A->>SBX: remove the run directory
Note over SBX: sandboxd deletes it with its disk after 2 idle minutes
The fork and the checkout happen at the first workspace tool call of the run.
run_command accepts only these commands:
bun install,bun test [paths], andbunx tsc --noEmitbun run typecheck|test|bench|contract|build, andbun run <name>for a script in the workspacepackage.jsongit status|diff|logandls
After bun run build (or bun run build:<x>), the workspace discards the files that the build wrote. Composition builds them again.
| Guard | Agent workspace | Candidate checks | Code edit session |
|---|---|---|---|
| Directory | /workspace/<last 24 characters of the run ID> |
/workspace/cand-<id> |
/workspace/edit-<hex> |
| Reservation on open | 300 s | 900 s | 480 s for each command. The open starts no Sandbox. |
| Idle tail before sleep | 120 s (sleepAfter 2 min) |
120 s (sleepAfter 2 min) |
120 s (sleepAfter 2 min) |
| Concurrency | 4 open workspaces per Change | Checks run one after another in one workflow | One command at a time in each session, one open session per person per Project |
| Command user | root, allowlisted commands | root | An unprivileged user of the session, any command, 120 s timeout |
| Monthly meter | SANDBOX_MONTHLY_SECONDS: 72,000 s (20 h). A settle charges only the awake time the use adds to the Project’s container (sandbox-meter.ts). |
Same meter | Same meter. At the cap: 429 sandbox_cap. |
A code edit session (ADR 0029) starts the Sandbox only at its first command. After each command, a kill as the session user stops every process of the session. The end of a session does the same while the Sandbox is awake. The end also removes the session directory. sandboxd then removes the idle Sandbox after 2 minutes.
A Sandbox loss, or an idle removal, keeps all committed work. The workspace restores from the same fork, base, and stacked merges. Then it compares the result with the recorded start commit. A Sandbox loss deletes uncommitted edits. A publish after a loss fails the run. Thus a run never publishes partial work. The Sandbox gets short-lived Git tokens only. It gets no model credentials and no token of the stack services.
Verification workflow
Section titled “Verification workflow”CandidateVerificationWorkflow has one instance per Candidate. Each step is idempotent. Each check is its own step with its own timeout. The workflow calls a model in two places only:
- The acceptance check: one Haiku call, and one Sonnet call when the Haiku output is not valid.
- The Integration Judge on a tie between sibling Candidates: one Sonnet call.
Both calls count against the Anthropic monthly budget, the daily call cap, and the Change budget.
flowchart TB
L["Load Candidate, Change,<br/>ChangeSets, canonical"] --> F{"Status and base"}
F -->|"verified, rejected, promoted"| SKIP["Skip"]
F -->|"stale, or base behind canonical"| ST["stale, then recompose"]
F -->|"Change cancelled"| RJ["rejected"]
F -->|"base = canonical"| CC{"checkCompatibility<br/>(deterministic)"}
CC -->|"incompatible"| RJ
CC -->|"ok or shared file"| FK{"Fork cand-id,<br/>reserve 900 Sandbox s"}
FK -->|"no Sandbox budget"| RJ
FK -->|"reserved"| CM{"Merge ChangeSets<br/>in Work Graph order"}
CM -->|"merge conflict"| RJ
CM -->|"merged"| BD["build script in package.json:<br/>bun install, bun run build,<br/>commit the output"]
BD --> PL["Plan, status verifying,<br/>candidate.composed"]
PL --> CK["One step per check:<br/>Evidence rows and objects"]
CK --> PV["Live app only: publish preview,<br/>run preview-smoke"]
PV --> GT{"gateRequiredChecks"}
GT -->|"promotable"| VF["verified"]
GT -->|"gaps"| RJ
VF --> SEL["Sibling selection"]
RJ --> SEL
- A rejection by
checkCompatibilityor by the merge recordscompositionEvidence with the reason. - A composition build that fails records the
buildEvidence (fail,checked_supported, the output tail). Thebuildcheck then does not run a second time. - After a passing composition build, the
buildcheck runs on its normal runner. - The Candidate patch leaves out the files that the composition build generated.
- A check step that throws records status
errorwith coveragenot_checked. A record with a duration over the check timeout is the gaptimed_out. A gap is never a pass. - The Sandbox meter settles after the last check, before preview publication and the gate.
- The preview publishes only when the
buildcheck (check IDbuild) passed. - After selection, a Change with no Candidate that can ship ends as
failed. See Change lifecycle.
Where checks run
Section titled “Where checks run”Core has the APPS binding, so build, unit, and contract run first in the apps service (isAppCheckerEligible in runner-selection.ts).
The APPS.check RPC loads an isolate through the Worker Loader.
The AppChecker reads the composed tree from the code store in the object store. Each check has at most 30 s, no network, and no binding.
When AppChecker answers supported: false, the check runs in the Sandbox. Examples are a missing code store, a bundler error, and a deadline.
Some unit checks are supported: false before any test runs (sandboxOnlyFeatures in workers/apps/src/checker-program.ts).
These are tests that read project files (import.meta.dir, __dirname, node:fs), use Bun globals, call process.cwd(), or start processes.
A loaded isolate has no project files on disk. Such a test would fail there because of the runner, not because of the code.
A completed failure in the apps service is final. The check does not go to the Sandbox for a second try.
performance, security, and browser always run in the Sandbox.
A protected-invariant-<n> check has a procedure and no command. It records a gap and runs nothing.
acceptance and preview-smoke use no Sandbox.
The Evidence summary ends with (runner: dynamic-worker) for a check in the apps service, or (runner: sandbox). dynamic-worker is the runner name in code.
Which checks run
Section titled “Which checks run”buildVerificationPlan (packages/evidence/src/plan.ts) selects checks from the policy tier and from what the ChangeSets touch.
flowchart LR
RISK["Change risk tier"] --> POL{"requiredChecksByRisk"}
POL -->|"low"| LOW["build, targeted"]
POL -->|"medium"| MED["build, targeted, contract"]
POL -->|"high or critical"| HIGH["build, targeted,<br/>contract, security"]
T1["Protected invariant"] --> INV["contract and unit, unit,<br/>or a procedure gap"]
T2["Text says p95"] --> PERF["performance: p95Ms<br/>below the limit"]
T3["Task touches authorization"] --> SEC["security and a human gate"]
T4["UI files changed,<br/>any risk tier"] --> BRW["browser (required)"]
T5["Project has a live app"] --> LIVE["contract, preview-smoke"]
T6["package.json has a build script"] --> CA["client-assets"]
- Verification uses the policy of the Project: the
project_policiesrow (migration0017_project_policies.sql). policyForRun(workers/integration/src/policy.ts) reads the policy once per run.- The plan, the approval gate, the auto-promotion tiers, and the protected resources of
checkCompatibilityuse this policy. - A Project without a row uses
DEFAULT_POLICY(policy-1inworkers/integration/src/candidate-composer.ts).DEFAULT_POLICYprotects no resource. - Rhumbatron rejects a Candidate as
invariant_violationwhen one of its ChangeSets declares a contract or data change on a protected resource of the policy. - The policy check
targetedruns theunitcheck. Every plan hasbuildandunit. DEFAULT_POLICYlistslow,medium, andhigh. AcriticalChange uses the strictest listed tier. A gap in the policy never lowers verification.DEFAULT_POLICYalso listsbrowserforhigh. The plan adds the browser check only from the UI rule below, at every tier.- The p95 limit is the number before
msin a sentence that saysp95: in the goal, the acceptance criteria, or the invariants. The default is 200 ms. - UI files are files under
public/, or files with the extension.tsx,.jsx,.html, or.css. - Protected invariants are the Work Graph invariants and the goal sentences that say “unchanged”, “must remain”, or “must not change”.
- An invariant that keeps existing endpoint behavior adds
contractandunit. - Another app-behavior invariant adds
unit. - An invariant that no test can prove becomes a required procedure check
protected-invariant-<n>-<slug>. Examples are email delivery and uptime. It stays a visible gap. - A latency invariant maps to the
performancecheck when the Change has a p95 constraint. - A Change with acceptance criteria gets the optional
acceptancecheck (see Acceptance check). - A composed
package.jsonwith abuildscript adds the requiredclient-assetscheck:- Client sources are the files under
src/client/and thesrc/paths that the build script names. A named file also adds its directory, exceptsrc/itself. - The check fails when a client source changed and no script or style that
public/index.htmlreferences changed. The live app would then serve a stale bundle. - The check passes when no client source changed.
- A page without
public/index.html, or without a local script or style, passes with that reason.
- Client sources are the files under
| Check ID | Kind | Command or procedure | Timeout |
|---|---|---|---|
build |
build | Apps service: bundle the app with the @cloudflare/worker-bundler library and load it. Sandbox: bun run typecheck. A failed composition build is the result |
120 s (30 s in the apps service) |
client-assets |
build | Only with a build script. Composition compares the composed commit with the base (see above). No runner |
30 s |
unit |
unit | Apps service: the project tests. Sandbox: bun test |
180 s (30 s in the apps service) |
contract |
contract | Compare the route table of the base and of the Candidate with the declared contract changes. Extraction in the apps service, or bun run contract in the Sandbox |
120 s (30 s in the apps service) |
protected-invariant-<n>-<slug> |
protected-invariant | A procedure for an invariant that no test can prove. No command runs, so it is a required gap | 180 s |
performance |
performance | bun run bench. Metric p95Ms must be below the limit |
120 s |
security |
security | bun test --test-name-pattern authorization. The app conventions (APP_CONVENTIONS) tell agents to name the denial tests this way. No matching test is a gap |
180 s |
browser |
browser | Required when UI files change. One session on the Playwright server (see Browser check) | 180 s |
acceptance |
acceptance | Optional. Haiku judges each acceptance criterion against the Candidate patch and the composed files. Code validates each citation | 180 s |
preview-smoke |
smoke | Publish the Candidate preview and run the release probes against it | 30 s |
Browser check
Section titled “Browser check”Every Candidate whose Change changes a UI file gets the browser check, at every risk tier. The check is required.
runBrowserCheck (workers/integration/src/browser-check.ts) runs it.
How the browser reaches the app:
- The check starts the composed app in the Project Sandbox with
bun run dev. - Core connects to the
browserservice over WebSocket (BROWSER_WS_URL,ws://browser:3000/). The service is a Playwright 1.64.1 server. - All network traffic of the browser goes through request interception.
- The check answers each request to the preview host from the Sandbox (
containerFetch). It blocks every other request. - The interceptor follows preview redirects itself (
followRedirects), up to 5 hops, while the target stays on the preview host. - A 307 or 308 redirect keeps the method and the body. Any other redirect becomes a
GETwithout a body. - The browser gets only the final response.
- The preview host has no public URL.
The flow is the same for every app. A failed step stops the flow.
- Open the app:
GET /answers below 400. The check saves a screenshot. - The home page shows visible text.
GET /api/health, sent from the page, answers 200.- Every linked page opens, by a click and by a direct load:
- The check explores breadth first from the home page, up to
MAX_PAGES(8) linked pages. It follows same-origin links only, and it skips/api/routes. - On each page, it fills and submits up to 3 forms. Thus the app makes data, and more links appear.
- The sample values are
12(number),person@example.com(email),2026-01-15(date),https://example.com(URL), andSample <n>(other text fields). - It ticks each checkbox and selects the second option of each select.
- It opens each link by a click on the page that links it. It then opens the same path by a direct load.
- Each page must show text. A page whose whole text starts with “404” or “not found” fails the step.
- The step fails on any uncaught page script error.
- The step fails on any 5xx response, and on any 4xx response for a page or asset outside
/api/./favicon.icodoes not count. - At the end, the check saves a second screenshot.
- The check explores breadth first from the home page, up to
An app whose client handles its own routes sets not_found_handling to single-page-application in its wrangler.jsonc (APP_CONVENTIONS). A direct load of such a route then serves public/index.html.
Limits:
| Limit | Value | Source |
|---|---|---|
| Session length | 120 s (BROWSER_SESSION_MS) |
check-semantics.ts |
| Check timeout | 180 s (BROWSER_CHECK_TIMEOUT_MS) |
packages/evidence/src/plan.ts |
| Runs per Candidate | 2, retries included (MAX_BROWSER_RUNS_PER_CANDIDATE) |
check-semantics.ts |
| Daily browser time | BROWSER_DAILY_SECONDS: 3600 s per UTC day in compose.yaml and services/core/src/config.ts. The code uses 500 s when the binding is absent or not a positive integer |
browser-check.ts |
| Reservation | 60 s before a session opens, settled to the measured seconds after it closes | check-semantics.ts |
| Screenshots | At most 8. Each has a SHA-256 content hash | browser-check.ts |
The meters are usage_counters rows: browser-seconds per UTC day, and browser-runs per Candidate.
Results (classifyBrowser in workers/integration/src/check-semantics.ts):
| Run result | Status | Coverage |
|---|---|---|
| Every step passes | pass |
checked_supported |
| A step finds wrong app behavior | fail |
checked_supported |
| A refused reservation (daily time or runs per Candidate) | skipped |
not_checked |
| No preview, or the browser does not connect | error |
not_checked |
| The session deadline, or a step that cannot observe the app | error |
not_checked |
A failed or missing browser check makes the Candidate unpromotable. The repair loop then starts (see Acceptance check).
Acceptance check
Section titled “Acceptance check”The acceptance check tests what the person asked for. Each Candidate of a Change with acceptance criteria gets it.
It is not a gate check. It does not make a Candidate unpromotable, and it does not reject a Candidate.
It ranks sibling Candidates, and it gates auto-promotion.
A failing required check (build, unit, contract, browser, preview-smoke, and the others in the table) makes a Candidate unpromotable.
Criteria (acceptanceCriteriaToJudge in candidate-composer.ts):
- The check judges the Change acceptance criteria.
- It also judges the kept criteria of the requests that went live while this request ran. Each one has the mark “Keep (from an earlier request)” (see Shared Projects).
- Both lists leave out the criteria that other checks cover:
- A criterion that only states the quality gate (“typecheck and tests pass”).
- A criterion that only guards against breakage (“other pages and API routes behave as before”).
- A latency limit. The
performancecheck covers it. - Kept behavior (“unchanged”, “must remain”). The invariant checks cover it.
- When no criterion is left, the plan has no
acceptancecheck.
Input (acceptanceSource in verification-workflow.ts, and acceptance-check.ts), in this order, at most 60,000 characters in total (ACCEPTANCE_SOURCE_CAP):
- The Candidate patch at
source/<project>/patches/candidates/<cand>.patch, when it belongs to the composed commit. It holds the hunks of changed text files only. - The full text of composed files from the Candidate fork, at most 24 files (
composedFileOrder):- the changed app files;
public/index.html, the local.jsand.cssfiles that it references,src/app.ts, andsrc/worker.ts;- the other changed files (tests and docs).
The check cuts a file that crosses the limit at a line boundary and marks it truncated.
It does not send a binary file or a file larger than 60,000 bytes.
When the fork cannot be read, the patch alone is the input.
Model:
- One Haiku call with the tool
record_acceptance. Each criterion ismet,not_met, orunclear. Ametcites{path, quote}. - The code reads a citation that names its quote
textin the same way. - An output that is not valid gets one Sonnet call. Examples are a wrong schema, a repeated or unknown criterion, and more or fewer than one tool call.
- When Haiku leaves a criterion of the request itself
unclear, Sonnet judges once. Its answer applies only to theunclearresults. - A person gets a question only when Sonnet also cannot tell.
- No browser session runs.
Citations (citationValid):
- Code keeps a citation only when the path is a file that the model received and the quote occurs in that text.
- In a composed file, the quote can come from any line. In a patch, the quote must come from added or context lines.
- A removed line proves a removal only when the citation has
removed: trueand no text that the model received contains the quote. - The patch includes deleted files, cut at 40 lines. Thus a deleted page can be cited.
- The quote has at least 8 characters after whitespace collapses.
- A criterion that code in the base satisfies is
metwith a quote of the composed file. - A
metwithout a valid citation counts asunclear.
Evidence:
passwithchecked_supported: each criterion of the request ismet, and no kept criterion isnot_met.- A kept criterion protects earlier work.
unclearthere means that the check found no regression. It does not block. failwithchecked_supportedin all other judged cases, with a summary such as “2 of 3 criteria met; not met: calendar view”.errorwithnot_checked(a gap): a model error, a refused call (Change budget, daily call cap, monthly budget), or no readable source.- When an earlier check has no passing result, the Candidate cannot ship. The check then records
skipped(not_checked) and makes no model call.
Repair before a person (acceptance-repair.ts):
- Each Change gets at most one repair Task (key
repair). A later shortfall goes to a person. - Triggers:
- The selected Candidate passed every required check, and the acceptance check found
not_metcriteria.unclearand gaps do not trigger a repair. - A person rejects a gated Candidate with a reason. The reason goes into the objective.
- A Candidate fails a required check or the preview check (status reason “Required checks not satisfied” or “Preview check failed”). This includes a failed or missing browser check.
- The selected Candidate passed every required check, and the acceptance check found
- The repair Task depends on every Task whose ChangeSet the Candidate holds. Thus its workspace holds all their work.
- Its objective lists the unmet criteria or the failure. For a failed check, the Task reproduces the failure and makes the code and tests of the request pass with the current app.
- The Change returns to
running. The next Candidate holds the repair. - A Decision Receipt (
ACCEPTANCE_REPAIR) records each repair.
Auto-promotion:
- A selected Candidate auto-promotes (when policy allows) only when its acceptance Evidence is
passwithchecked_supported. - Otherwise
holdForAcceptance(candidate-selection.ts) does these steps:- It adds the human gate to the plan.
- It sets the Candidate status reason.
- It moves the Change to
awaiting_approval. - It records
approval.requestedwith the reason, for example “Best candidate meets 2 of 3 criteria; not met: calendar view. Approve to ship it or cancel the change.”
- A person then approves and ships the Candidate through the normal path, or cancels the Change.
Storage: the per-criterion results go to candidates.acceptance_json and to the report object. criteriaMet, criteriaTotal, and costUsd go to metrics_json. The cost counts against the Change budget.
Sibling Candidates and selection
Section titled “Sibling Candidates and selection”The fork rule (planCandidateSets) can give one Change more than one Candidate on the same base generation.
These Candidates are siblings. Their workflows run in parallel. No sibling promotes alone (ADR 0026).
flowchart TB
G["Candidate gated:<br/>verified or rejected"] --> S{"Siblings on<br/>the same base?"}
S -->|"no"| P["Promotion path"]
S -->|"yes"| W{"Any sibling still<br/>composing or verifying?"}
W -->|"yes"| END["Run ends; the last sibling selects"]
W -->|"no"| CL{"Claim: UPDATE anchor<br/>selection_json from NULL"}
CL -->|"another run holds it"| END
CL -->|"claimed"| R["Rank the group"]
R -->|"one best"| A["Apply: losers rejected,<br/>Decision Receipt, candidate.selected"]
R -->|"tie"| J["Integration Judge<br/>(one Sonnet call)"]
R -->|"none promotable"| A
J --> A
A -->|"winner"| P
A -->|"no valid preference"| H["Change awaiting_approval:<br/>a person picks"]
A -->|"no winner"| FL["Change failed when no<br/>Candidate can ship"]
Integrate rule (applied before the fork rule, workers/integration/src/integrate.ts):
-
Two Tasks of one Change are complementary when their planned semantic resources do not overlap. Examples are
todos:apiandtodos:ui, andmissions:calendar-uiandmissions:kanban-ui. -
Complementary Tasks are parts of one plan.
-
A
confirmedorfork_requiredconflict between them gets the fork decisionintegrate(integrationForinpackages/conflict/src/integrate.ts). This rule applies at detection and after each model review. -
The dependency direction comes from the first rule that applies:
- An existing dependency path.
- The UI Task depends on the API Task.
- A UI Task has a resource whose last segment is
ui,frontend, orpage(alone or after-), or a title that says UI, page, or frontend. - An API Task has a resource whose last segment is
api(alone or after-), or a title that says API.
- A UI Task has a resource whose last segment is
- The Task whose Intent uses the contract of the other (
consumedContracts) depends on it. - The higher Task key depends on the lower.
-
Composition applies one integration per round, before any Candidate exists. One database transaction does these steps:
- It resolves the conflict (
review_json.integrate). - It adds the edge to
task_dependencies. - It rejects the ChangeSets of the dependent Task and of the Tasks built on it, and resolves their other open conflicts.
- It resets those Tasks to
pendingwith a fresh attempt window. - It moves the Change from
verifyingtorunning. - It records the Decision Receipt
CONFLICT_INTEGRATION.
Then the Event
conflict.integrated, the Challenge resolution (upheld), and the dispatch follow. After a lost delivery, the nextcandidate.createrepeats them (recoverIntegrations). - It resolves the conflict (
-
The dependent Task runs once more on a stacked workspace that includes the ChangeSet of the other Task.
-
Its instruction is to use the real routes, field names, and data shapes of that ChangeSet.
-
No Candidate composes until this run completes and the Change records
verification.startedagain. The Candidate then holds both ChangeSets. -
An integration occurs once per Task pair. A later conflict on an integrated pair follows the fork rule.
-
Only Tasks on overlapping semantic resources with incompatible designs are fork alternatives.
Contract declarations (parseContractChange in packages/conflict/src/declaration.ts):
- The parser converts the contract lines of an Agent into typed contract changes.
- A route in a declaration keeps only its method and path. Trailing punctuation and a query string (
GET /api/report?month=YYYY-MM) are not part of the route. - HTTP method names (
GET,POST,PUT,PATCH,DELETE) are part of a route. They are never change verbs. DELETE /api/todos/:id -> {ok:true}changes that route. Only wording such as “Removes DELETE /api/todos/:id” is a removal.- A line that says “None” or “No change” is not a contract change. A line that only uses, consumes, or calls routes is not a contract change.
- The routes of such a line go to the
consumedContractsof the Intent. Conflict detection never compares them.
Fork rule:
- A fork conflict counts only when both sides have at least one proposed ChangeSet.
- No fork conflict counts: one Candidate with every proposed ChangeSet.
- The first counting conflict is
confirmed: one Candidate per side. Each Candidate also holds the shared ChangeSets. - The first counting conflict is
fork_required(the conflict review saidneeds_more_evidence): the two side Candidates, plus a combined Candidate. - The combined Candidate holds the shared ChangeSets, both sides, and the ChangeSets that need both sides. It shows whether the sides work together, which is the Evidence that the review asks for.
- A Change has at most
MAX_CANDIDATES(3) Candidates per round. The ChangeSets of a further counting conflict wait for a later round.
Selection:
- The anchor is the earliest Candidate of the group (
created_at, then ID). - One conditional
UPDATE candidates SET selection_json = ... WHERE selection_json IS NULLon the anchor claims the selection. A retry by the claimant continues. Each other run stops. - Order:
- Only promotable Candidates (each required check passed).
- Most acceptance criteria met.
- Fewer gaps and optional failures.
- More passing checks.
- A tie goes to the Integration Judge:
- It reads only the Evidence snapshot of the tied Candidates: Evidence summaries and acceptance results. It fetches nothing.
- Its verdict is closed:
prefer_candidate(with a Candidate ID),reject_candidate,needs_more_evidence, orescalate_to_human. - Code validates the cited Evidence IDs. Only a valid
prefer_candidateselects a winner. - Any other result moves the Change to
awaiting_approval. A person then promotes one Candidate.
- Each verified loser becomes
rejectedwith a reason, for example “Lost to cand_x: met 3 of 3 criteria, this met 2 of 3.” - Each Candidate of the group stores the outcome in
selection_json. The decision is a Decision Receipt (CANDIDATE_SELECTION) and the Eventcandidate.selected. - Only the winner goes on the promotion path: a canonical check, the acceptance hold, policy
autoPromoteRiskTiers, the approval gate, and the compare-and-swap. A gated winner getsapproval.requested. - A Candidate without siblings goes on the same path.
- A verified winner that cannot auto-promote moves the Change to
awaiting_approval. The Candidate status reason states why, andapproval.requestedcarries the samereason:- Plan gate (
trigger: "policy", from the gate step or the selection): “Policy policy-1 requires a person to approve high-risk changes. Approve to ship it or cancel the change.” An authorization change reads “changes to authorization”. - Policy hold (
trigger: "policy",holdForPersonincandidate-selection.ts): the plan has no gate, but the stored policy gates the tier or does not list it as automatic. A Change with no risk tier also gets this hold. The hold adds the human gate to the plan (policyHoldReasoninpackages/evidence/src/promotion.ts). - Acceptance hold (
trigger: "acceptance"), above.
- Plan gate (
- A manual promotion is blocked (
selection_pending) while a sibling composes or verifies.
Coverage states and the gate
Section titled “Coverage states and the gate”Each Evidence record has a status (pass, fail, error, skipped) and a coverage (not_checked, checked_no_signal, checked_supported).
gateRequiredChecks reads the latest record of each required check.
flowchart TB
R["Required check"] --> E{"Any Evidence?"}
E -->|"no"| G1["gap: missing"]
E -->|"yes"| TO{"Duration within timeout?"}
TO -->|"no"| G2["gap: timed_out"]
TO -->|"yes"| S{"status"}
S -->|"skipped"| G3["gap: skipped"]
S -->|"error"| G4["gap: error"]
S -->|"fail"| G5["gap: failed"]
S -->|"pass"| C{"coverage"}
C -->|"not_checked"| G6["gap: not_checked"]
C -->|"checked_no_signal"| G7["gap: no_signal"]
C -->|"checked_supported"| M{"metric_below met?"}
M -->|"no"| G8["gap: expectation_not_met"]
M -->|"yes or not a metric"| OK["satisfied"]
When two records have the same completion time, the record that does not pass is the latest. The UI shows each gap. It never shows a gap as green. A model finding must cite what the model received:
- A conflict review cites Context Packet item IDs.
- The acceptance check cites quotes of the patch or of the composed files.
- The Integration Judge cites Evidence IDs.
A finding with a bad citation is unsupported. It cannot change state.
Promotion preconditions
Section titled “Promotion preconditions”Promotion has two deterministic layers (ADR 0013). The integration code reads the authoritative database rows and the git service. It checks these preconditions:
- The Candidate is verified.
- The required Evidence exists and passes.
- Each required human approval exists.
- The composed commit is in the Candidate fork.
- The plan policy version equals the active policy version.
ProjectRootDO then checks the expected Canonical Generation, the expected canonical tree, and the policy version in its own transaction. The integration code reports every failed precondition at once.
flowchart TB
REQ["POST /v1/candidates/:id/promote"] --> P0{"Sibling still<br/>verifying?"}
P0 -->|"yes"| BLK["blocked: list every block"]
P0 -->|"no"| P1{"Candidate verified<br/>and base = canonical?"}
P1 -->|"base behind"| STALE["mark stale, then recompose<br/>or block the Change"]
P1 -->|"not verified"| BLK
P1 -->|"yes"| P2{"Plan present and<br/>every required check passes?"}
P2 -->|"no"| BLK
P2 -->|"yes"| P3{"Approval granted<br/>for this composed tree?"}
P3 -->|"pending or rejected"| BLK
P3 -->|"granted or not required"| P4{"Plan policy = active policy,<br/>composed commit in cand fork?"}
P4 -->|"no"| BLK
P4 -->|"yes"| CAS{"ProjectRootDO.promote:<br/>generation and tree match?"}
CAS -->|"already promoted"| DONE["return alreadyPromoted"]
CAS -->|"mismatch"| STALE
CAS -->|"policy mismatch"| BLK
CAS -->|"match"| ADV["One transaction:<br/>generation + 1, canonical.advanced"]
ADV --> SYNC["Fast-forward main,<br/>start release,<br/>settle older-base Candidates"]
Approval rules (packages/evidence/src/promotion.ts):
- A human gate applies when the plan requires one: an authorization change, or the acceptance hold.
- A human gate also applies when the policy gates the risk tier (
high,critical). - A grant counts only for the exact composed tree that the approver saw.
- The first decision is final. A rejection also rejects the Candidate.
- No model decides approval or promotion.
The idempotency key is the Candidate ID plus the expected generation.
A retry after success returns alreadyPromoted. It repairs the database mirror and the source sync.