From a71d1e744bfed3245f1f31331648aa0da7dce220 Mon Sep 17 00:00:00 2001 From: sprooty Date: Thu, 6 Aug 2026 02:41:39 +0000 Subject: [PATCH] docs: one design document, written against the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECTURE.md, INTERNALS.md and COORDINATION-PLANE.md overlap by roughly half and disagree with each other and with the code in several places. This adds docs/DESIGN.md: how the harness works and why it is shaped that way, current design only. Status, phasing and what has been proven belong to docs/STATUS.md and are not duplicated here. Structured around what a reader needs rather than around how the code grew: the one idea, the pipeline and what each stage guarantees, the invariants each paired with the failure that produced it, model routing, the dependency graph, state and durability, project isolation, the API, and the extension points that make "add a vendor without editing core" true rather than aspirational. Written against the modules, not the documents. Where they disagreed the code won, and the disagreement is named rather than smoothed over: - The implementer is asked for edit blocks and the harness computes the line numbers (D10, reopened 2026-08-05). Neither older document mentions edits.py at all; both still describe the change protocol as a diff. - outcomes.py carries six dispositions and nineteen reason kinds. The older documents say five and twelve. - The message ledger, rooms, command service and oversight actor are built and tested, not "proposed, not built" — but they are unwired, which is the fact worth recording. - A worktree per item is the session executor; the direct executor branches inside one checkout. - Claim paging is keyset over (attempts, item_id), not a single LIMIT. - The live event table shares the queue's file; the audit table is the one in its own database. Adds only this file. The older documents are left for the deprecation and link pass that owns them. Co-Authored-By: Claude Opus 5 (1M context) --- docs/DESIGN.md | 1197 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1197 insertions(+) create mode 100644 docs/DESIGN.md diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..16e36dd --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,1197 @@ +# Design + +How the harness works, and why it is shaped that way. + +This is the design document. It describes the system as the code implements it +today — not a roadmap, not a phase order, not a record of how any part got +here. **Where a claim here disagrees with the code, the code is right and this +document is wrong**; that is rule 7 of [`AGENTS.md`](../AGENTS.md) and it +applies to this file first. + +What is built, what is half-built, what has never run against a real fleet, and +what is blocked on what: [`docs/STATUS.md`](STATUS.md) owns all of it. Nothing +here is a status claim, and where a design element exists but is not yet +reachable, this says so in one clause and moves on. + +--- + +## 1. What this is, and the one idea + +A queue and a delivery pipeline for a fleet of coding agents. + +``` +PLAN.md ──▶ issues ──▶ claim ──▶ implement ──▶ checks ──▶ review ──▶ PR + │ + every stage recorded, append-only +``` + +You supply the plan, the model provider and the checks. The harness supplies +the queue, the leases, the failure model, the gates and the record of what +happened. It writes no code itself and judges no code itself; it decides *what +runs next*, *whether the result may proceed*, and *what is true about it +afterwards*. + +Three properties define it, and every design decision below serves one of them. + +**It is generic.** Not tied to a project, a language, a workload or a vendor. +That is an owner ruling, and it is enforced rather than trusted: the +authoritative list of what core may not know lives in `EXECUTION_PATH` in +`tests/test_generic.py`, not in prose. Core holds no log paths, no directory +conventions, no numbers belonging to one workload, no import of anything under +`adapters/` — and **no dotted path naming one**, because a lazy import written +as a string is still core knowing what a particular vendor is called. See §9. + +**It is provider-agnostic in the strong sense.** A call site names a *role*, +never a model. What speaks HTTP, what carries the credential, what reads the +answer and what a failure *means* are four separate, replaceable things bundled +under one name. Adding a vendor changes no core module and no core module +imports it. See §4. + +**It is honest about what happened.** A gate that passed and a gate that was +never asked are different answers. An unpriced call is not a free call. A +merged pull request that was later reverted produces two facts in order, not +one fact that changed its mind. The audit database has no mutation surface at +all, so a number in it cannot be improved after the fact — including by us. + +The unifying idea, from which most of the rest follows: + +> **The gates are the product. Everything else is scaffolding around them.** +> A gate is never weakened, made cheaper, made skippable or made optional in +> order to make the queue, the dispatcher, the API or a dashboard easier to +> build. If a change does that, it is the wrong trade. + +--- + +## 2. The execution pipeline + +One item, from claim to pull request. Every stage below is a boundary at which +the harness can stop, and each one guarantees something the next stage relies +on. + +``` + claim (a LEASE) + │ + base resolution ──── depends on an item already delivered? + │ yes → branch from ITS branch, not from main + sync the checkout + │ + planner ──────────── names the files, says what it cannot identify + │ + context selection ────── primary target does not fit? ESCALATE, do not guess + │ + implementer ────────── asked for EDIT BLOCKS; the harness computes the diff + │ + validate the patch ──── before git is touched at all + │ + cut the branch + │ + apply ────────────── tolerance ladder; the APPLIED diff is what proceeds + │ + checks ───────────── the cheap gate, and it runs FIRST + │ + readiness re-check ────── the same call admission made, at the last cheap point + │ + CHECKPOINT ──────────── local commit, then push, then a DRAFT pull request + │ + review ───────────── a different role, ideally a different vendor + │ + mark ready ───────────── approval takes the draft out of draft. Never merges. +``` + +### What each stage guarantees + +| Stage | Guarantee | +|---|---| +| **Claim** | Exactly one worker owns the item, for a bounded time, and losing the process releases it without anyone doing anything (§3.1). | +| **Base resolution** | An item written against another item's result is branched from that result. Applying such a change to a base missing the function it assumes either fails, or — with fuzzy matching on — succeeds in the wrong place. Only **one** dependency can be stacked on: with several unmerged dependency branches there is no single correct base without merging them, so the first is used and the fact is reported rather than hidden. | +| **Tree sync** | The tree is at the item's base *before* anything reads it, and before the branch is cut. The context selector reads the file at the base while the edit applier reads the working tree; when those disagreed, the second item to touch a file was told its own correctly-quoted text did not occur. The branch is cut late, so an item producing no usable diff leaves no branch behind. | +| **Planner** | Named targets, in importance order, plus an explicit `cannot_identify_target`. That last field is a first-class answer, not an error. | +| **Context selection** | The implementer is never asked to change a file it was not shown. If the primary target alone exceeds the budget the item escalates (`context_unavailable`) rather than proceeding — retrying cannot change the size of a file. | +| **Implementer** | A change expressed as text to find and text to put there. See §2.1. | +| **Patch validation** | A reply that is not a well-formed diff is diagnosed as a *model* failure before git is involved, because once it reaches `git apply: corrupt patch at line 549` it is indistinguishable from a patch written against the wrong base — and the two are fixed in completely different places. | +| **Apply** | Either the change is in the tree, or the branch is destroyed and the patch is kept on disk for whoever diagnoses it. | +| **Checks** | The project's own commands answered on the *applied* tree, with five distinct outcomes (§6.4) — not a boolean. | +| **Readiness re-check** | The item is still eligible *now*, at the last point before money and durability are spent. | +| **Checkpoint** | Work that passed every cheap gate survives the worker dying during the expensive one — and does not present itself as reviewed while it waits (§3.5). | +| **Review** | A verdict from a role that did not write the change, failing closed in two independent ways (§3.6). | +| **Mark ready** | Approval takes a draft out of draft. **Nothing is ever merged and nothing is ever committed to the default branch.** A wrong answer stays reviewable — a rejected item keeps its branch and its draft, with the verdict recorded on it, because a rejection somebody has to read is worth more than a tidy repository. | + +### 2.1 The change protocol: edit blocks, not hunk headers + +The implementer is asked for edit blocks and told, in as many words, not to +write a unified diff: + +``` +path/to/file.ext +<<<<<<< SEARCH +the exact existing text to find +======= +the exact text to put in its place +>>>>>>> REPLACE +``` + +A unified diff asks a model to do arithmetic it cannot check. `@@ -401,7 ++401,12 @@` declares how many lines the hunk consumes and produces, and the +model has to get both right, blind, from having read the file once in a prompt. +Measured against a real repository on 2026-08-05, two items in one run, four +model calls all returning HTTP 200 against a healthy gateway, **zero delivered** +— `hunk ends 0 source and 7 result line(s) short of what its header declares` +and `the last hunk supplies 1 fewer source line`. Neither model misunderstood +its item. Both miscounted, and the arithmetic gets harder as a file grows. + +An edit block has no line numbers, so there is nothing to miscount. The text +either occurs in the file or it does not, and that is a question the harness can +answer *before* changing anything. Three rules make the answer definite: + +- a match must begin and end on **whole-line boundaries**, so an edit naming + `foo` cannot rewrite the middle of `foobar` into something that still + compiles and means something else; +- text occurring **more than once is not a location** — ambiguity is refused, + never resolved by taking the first hit; +- the one tolerated fuzziness is a **uniform** indentation shift, applied only + when the match is still unique, with the file's indentation winning. + +`edits.py` then computes the unified diff from file content the harness has +read. That is the point of doing it here: **every gate downstream is +untouched** — the validator, the apply ladder, the checks, the reviewer and the +commit all still see a diff, and none of them learns a second way for changes to +arrive. + +A unified diff is still *read*. Models that ignore the instruction, an +implementer route with its own habits, and every durable attempt recorded before +this change all still work; refusing them would turn a format preference into an +outage. **Whole-file replacement is rejected** and exists only in the +experiment that measured it — its result was a change placed in the wrong file, +which is the finding that boundary exists to keep out of core. + +`edits.py` also owns the *file-write* boundary: a path resolving outside the +working tree is refused. That is deliberately not the command guard's job, and +neither is the other's fallback — a write that never goes through a command is +invisible to the guard, and a command that never writes a file is invisible +here. + +### 2.2 The apply ladder + +A patch that parses can still be refused by git for reasons that are worth +telling apart, so `apply_diff` walks rungs and reports which one worked: + +1. `git apply` +2. `git apply --unidiff-zero` — understated hunk headers are the single most + common model error, and this is what forgives them +3. `patch -p1 --fuzz=3` — **off by default**, opt-in per deployment + +each tried against the diff as written and, if they differ, against a recounted +form. One refusal precedes the ladder entirely: a hunk header claiming +`@@ -0,0` against a file that exists with content is rejected outright, because +`--unidiff-zero` would "succeed" by inserting the whole thing at line 1. + +The ladder is why the *applied* diff — `git diff HEAD`, re-read after the apply +and again if a declared fix touched the tree — is what the reviewer sees. +Reviewing the model's text instead would reject good work for an artefact of +the plumbing and, worse, make the gate structurally unable to catch a diff that +claims more than it did. + +### 2.3 Three ways to run an item + +The pipeline above is the direct-API executor (`executor.py`). Two others share +its gates: + +- **Hosted session** (`session_executor.py`) runs the item as a CLI agent in a + terminal a person can attach to, in a real `git worktree` of its own — this + is the executor the phrase "a worktree per item" describes; the direct + executor branches inside one checkout, and running two of it against one + checkout would be the data race that phrase warns about. It has + no planner, no context assembly and no resumable attempts; completion is the + process **exit code, never idleness**, because an agent that has printed its + answer and one waiting for approval look identical from outside. A timeout + does not kill the session — it holds the agent's context, which is the only + thing that makes the item resumable by a human — so the session is recorded + as abandoned and reaped later. From the checks onward it is the same + sequence, and where the two executors once disagreed about a gate, they no + longer do. + +- **An agent loop** (`adapters/minisweagent.py`) supplies what the other two + lack: turns. Its `Model` routes every call through `ModelClient` — so + fallback chains, the retry ladder, per-endpoint parking, classification, + pricing and the recorded answer all still apply, and the loop never learns + what a provider is — and its `Environment` puts every command the agent runs + through the same `CommandGuard` that screens check commands. A call is billed + before its body is parsed, because a reply nobody could parse was still paid + for and a ceiling that counts only the calls that went well is not a ceiling. + **It is not yet reachable from `run`** (#215): the queue, the gates, the + audit, the attempt record and the budgets have never seen a loop-executed + item, and its budget mapping is therefore enforceable rather than enforced. + +Which files inside the repository an agent touches is deliberately *not* +constrained. An agent using the whole repository to reach an outcome is how +work gets done; "it changed something the item did not ask for" is a question +for the reviewer. The guard bounds what is **dangerous**, not what is untidy. + +**The direction of travel.** Issue #195 reframes the single-shot model call as +the defect rather than any one prompt or format: every role that answers +questions about a repository — planner, implementer, reviewer, surveyor, +assessor, scoper — is today a context someone guessed at, one call, and parsed +text, and the correct shape is a bounded loop with tools and, for the gates, +read-only access. This document describes the pipeline that exists. + +--- + +## 3. The invariants + +Each of these is a rule, and each rule is a measured failure wearing a +different hat. They are the part of this document worth arguing with; the rest +is arrangement. + +### 3.1 A claim is a LEASE, not a lock + +A worker claims an item until `now + 900s` and extends that with a heartbeat +while it works. It does not hold a lock. + +A lock held by a dead process is a lock nobody can release, and the usual +workaround — a human clearing stale state — is *exactly* the unattended failure +the queue exists to prevent. A worker killed mid-item releases its work by doing +nothing at all. + +The contract has two halves and for a while only one was enforced: + +- **`heartbeat` is owner-guarded.** A refused heartbeat means someone else owns + the item now. The worker raises `ClaimLost`, stops, and **releases nothing** — + reporting anything at that point would overwrite a live claim. The durable + attempt buffer is discarded for the same reason: it belongs to an attempt + somebody else now owns. +- **`release` is owner-guarded too.** It was not, and a stalled worker could + surface late and mark an item done from work the new owner never did, leaving + the new owner running with nothing to release. + +Omitting the owner on release is an *administrative* override, and stays +possible on purpose: an operator retrying a wedged row through the API has no +worker identity, and guarding that would remove the one lever a human has. + +Two independent detectors exist because they answer different questions: a +background thread beating at a third of the lease notices a loss *during* a +stage, and a synchronous check at the next boundary is what turns that into a +stop. Both are read. The heartbeat returning a value nobody read is how a worker +carried on after losing its claim and then reported a result for someone else's +item — measured once as fifteen minutes of a 915-second agent run. A database +error is deliberately *not* treated as a lost claim; only a heartbeat the queue +actually refused is. + +The claim itself is one `BEGIN IMMEDIATE` transaction covering both the +selection and the update, so two workers racing cannot both win — the loser's +transaction sees the row already claimed. Two things happen deliberately +*before* that transaction opens: the project's control state is read (only +`running` claims at all), and expired holds are swept, because a claim scan is +the moment the queue's view of what is available has to be true. + +Ordering is `attempts, item_id`, so an item that has failed twice sinks below +one never tried and a poison item cannot monopolise a worker; it gets a turn, at +the back. Candidates are read a page at a time rather than all at once, because +loading the whole eligible backlog inside the write transaction held the write +lock longer the larger the backlog got. **The page is keyset-paged on +`(attempts, item_id)`, and the scan walks pages until it finds something or runs +out** — with a single bounded query, "the queue has ready work" and "the first +page has ready work" become the same question, and the answer is a permanently +stalled fleet with a full queue. + +Ineligible candidates are handled inside the same scan: an item at or over +`max_attempts` is retired to `exhausted` in place and the walk continues, and an +item whose required dependencies do not resolve is skipped (§5). `max_attempts` +is re-read on every claim rather than cached, so raising it rescues exhausted +items without a restart. Setting it to zero disables the ceiling. + +A re-claim that finds a **resumable durable position keeps its attempt number** +(D11): a crash is not a failure of the work. The consequence is named rather +than hidden — an item that crash-loops is then bounded by the wall-clock and +spend budgets rather than by the attempt counter. + +### 3.2 Checks run BEFORE the reviewer + +Paying a model to tell you the build is broken is paying the dearest gate to +catch what the cheapest one already caught. + +So the project's own check commands run on the applied tree first, and a +non-passing result ends the attempt without a reviewer call. The implementer is +*told* what those commands are — a diff refused by a formatter the model was +never shown costs an attempt and a model call to discover something the harness +knew before it asked. Naming the commands is not weakening the gate: the gate +still runs, and still refuses. + +A check may also declare a **mechanical fix**. Running it is off by default and, +where an operator turns it on, it is fenced so that it cannot become a way for +the gate to pass itself: one fix per check, never in a loop, a structural change +to the tree escalates rather than proceeding, the fix argv is screened like any +other, **and the re-run is the verdict** — a fix that does not clear the gate is +still a failure. The reviewer is told in as many words that the harness itself +modified the tree and which lines are not the agent's work, because the +alternative is a gate judging changes whose author it has been misled about. + +### 3.3 Never retry a spend cap + +`429` is at least four different facts: *slow down* (retry in a moment), *your +5-hour budget is gone* (hours), *your weekly budget is gone* (days), and *we +refuse this* (never). Vendors bury the difference in a body field. + +Retrying a spent cap is a busy-wait that burns quota checking whether quota +exists. So the harness classifies first, and `window_cap` and `terminal_cap` +park the endpoint rather than retrying it — ever. When every route in a role's +chain has answered with a cap, the item is released back to `pending`, not +`failed`: nothing was wrong with the work, the account ran out of money, and +the worker sleeps a poll and asks again rather than exiting. + +The mirror of this rule matters just as much. A **deployment's own** ceiling on +one item is a different fact, it never parks an endpoint, and it never enters +the never-retry set — see §6.5. + +### 3.4 No global state in the retry path + +One worker's rejection must never pause another worker. + +A *fleet-wide* cooldown in response to one worker's 429 does not merely stall +the fleet — it **phase-locks** it. Every worker wakes together, bursts together +and is limited together, which is precisely the shape a rate limiter exists to +reject. + +So every reaction is scoped to the worker and the endpoint that saw it, and the +backoff is jittered so that workers which *did* collide do not re-collide. The +cap bounds the curve, not the result: capping after jittering re-synchronises +exactly the workers the jitter was spreading out. + +Parks are keyed by `(endpoint, role)` rather than by endpoint alone, and the +reviewer and planner roles are ringfenced from endpoint-wide parks — an +implementer exhausting a gateway must not take the gate that judges its work +down with it. + +### 3.5 Checkpoint before the expensive gate + +Review is the slowest and most failure-prone call in the pipeline. Work that +has passed every cheap gate must be durable before it runs. + +So the checkpoint is a real local commit — then a push, then a **draft** pull +request — all taken *before* the reviewer is asked, and each recorded as a +durable attempt artefact. The commit's own trailer says what it is: + +``` +Reviewed: not yet — this is a checkpoint taken after the cheap gates passed +and before review. +``` + +Both halves are load-bearing. Durable, so a worker dying during review does not +lose work that had already passed everything cheap. **And plainly unreviewed, +so nothing presents itself as reviewed until approval exists.** A checkpoint +that looked like a finished pull request would be this invariant paying for +durability with the reviewer's credibility. + +The two effects that are not idempotent — the push and the draft PR — are +bracketed by intent records, so a crash in that window is discovered as +"began and did not confirm" rather than as a push that may or may not have +landed, found later by someone reading git. + +### 3.6 The reviewer is independent, and fails closed twice + +The reviewer is a separate role, and a call site can never name a model, so +pointing it at a different vendor is a data change. + +It fails closed in two independent ways: + +- **No reviewer configured returns `REJECTED — nothing reviewed this`.** Absence + is not approval. +- **Any verdict not starting with `APPROVED` is a rejection**, so a truncated + or malformed reply rejects. + +The asymmetry is deliberate. Approving work that does not do what was asked +reaches a pull request carrying the word "reviewed" and costs someone much +later; an unnecessary rejection costs one retry. + +Cynicism is constructed rather than asserted. The prompt opens by telling the +reviewer to assume the change is wrong, and requires two lists before the +verdict means anything: **what I verified** (naming nothing is itself a +rejection) and **what I could not verify**. The second list is the useful one — +most changes that fail review fail because they did something *adjacent* to what +was asked, or claimed more than they did, not because they were obviously +broken. The reviewer is given the touched files in full at their post-change +state, not only the diff, so "the diff does not show it" stops being an +available answer about a file it can read. + +A verdict may also carry **follow-ups**, and those become proposed work items +for a person to accept or discard. This exists because the alternative is +worse: refusing work that did what it was asked, because the reviewer would +additionally have done something else, discards the work *and* the observation — +the item goes back to be rewritten identically and nothing records what was +noticed. + +Independence is **reported, not enforced**. `reviewer_independence()` compares +the reviewer's route to the implementer that actually runs and says when they +are the same model, or merely the same vendor: + +``` +WARNING: reviewer and implementer are the same model (m): + every review is a model grading its own work +``` + +Running a single model is a legitimate deliberate choice and blocking it would +be the harness overruling an operator about their own budget. What it must not +be is a surprise. In session mode the implementer is an agent process rather +than a routed model, and the report says so rather than comparing two routes +that never meet. + +### 3.7 A refusal by policy is terminal + +`guard.py` screens the argv the harness itself executes — a plan's `verify:` +line, the agent command, a project's check commands — all of which are read from +documents and configuration a model may have written. Two separate rules: a +deployment-configured refusal list matched against argv, and a path boundary +that refuses any argument naming a path outside the directory the harness chose. +The boundary is what makes `~/.ssh`, `/etc` and `rm -rf /` unreachable without +the guard having to enumerate them. + +The working directory *is* the boundary, on purpose: one configured separately +from the directory the command runs in is one more thing that can be set wrong. +Matching is argv-aware rather than textual, so neither an absolute path nor a +reordered flag is an evasion. The built-in default list is deliberately tiny and +names nothing belonging to any workload — privilege escalation, host lifecycle, +force-push — because "what must never run here" is a property of a deployment +and not of a framework. A guard nobody configured is reported by `doctor` as +*not configured*, which is not the same as a pass. + +It is **screening, not instruction**. Telling the implementer not to reach for +`sudo` asks the least reliable component in the system to enforce the +constraint; the refusal holds whether the model is right, wrong or adversarial. + +In the executors a refusal is **terminal** (owner decision, 2026-08-05): the +item stops with the disposition `blocked_by_policy` and the rule that fired, and +is not handed back to the agent as a correction. Terminal is the safest of the +two answers — a guard that answers can be probed — the cheapest, and it cannot +loop. **The cost is real and is not hidden**: an agent that reaches for a +forbidden command when a permitted equivalent existed loses the whole item and +needs a person, and `doctor` reports the policy so whoever pays that can see +what it is. + +Inside the agent loop the same refusal is returned to the agent as observation +text rather than raised. That is the opposite answer, and the reason is that the +loop's turn budget already bounds it: nothing is silently permitted, the refusal +is recorded, and a loop that keeps trying refused commands runs out of steps. +The message names the tree and says the rule is not transient, because the +measurement behind it was 40 model calls of which 15 turns were spent being +refused by a message that never said where the boundary was. + +An agent's shell line is not one command, so the loop's environment splits it +into the simple commands a shell would actually run — quote-, substitution-, +wrapper- and heredoc-aware — and screens each. Its `execute` is one method, and +one method is the entire attack surface. + +**It is not a sandbox**, and nothing here should be described as one. The +session host owns process isolation, and a second worse copy of it does not +belong here. It cannot see inside an inline program — `sh -c '…'` and +`python -c '…'` are one argv token — so a deployment that runs a shell as a +check has an unscreened shell. Pattern matching bounds the obvious reaches, not +the clever ones: it converts a class of catastrophic outcomes into a legible +refusal, not into an impossibility. + +### 3.8 Unknown is never zero, and history is never rewritten + +Zero tokens claims a call was free. A call whose price is unknown is recorded as +**unpriced** and counted separately, never folded into a total. + +Every model call records the tokens **and the price applied to them**, because +applying today's rates to last year's usage is a projection rather than history +and it silently rewrites the past on every vendor repricing. A price change then +shows as a step in the series instead of an invisible retroactive edit. + +The same rule at the top: rate limits from logs written before classification +existed are `unclassified` and never folded into `rpm` / `window_cap` / +`terminal_cap`. The baseline is a total, the successor is a breakdown, and the +panel says so. Removing that caveat to make a page tidier would present a delta +that does not exist. + +--- + +## 4. Model routing + +### 4.1 Roles, not models + +A call site names a role. Roles are open strings, not an enum — `planner`, +`implementer` and `reviewer` in the pipeline; `scoper`, `surveyor` and +`assessor` elsewhere — and a role resolves to a route through a map stored in +the queue database. + +Storing the map is what makes re-routing a data change rather than a code +change: `PUT /api/roles` moves the reviewer to a different vendor mid-run with +no restart. It lives in the database rather than in memory because the API +process and the worker process are different processes, and an in-memory value +could never be changed from outside the loop using it. + +**The stored map wins over the command line** by default, per role. That is the +point of storing it, but silently ignoring flags someone just typed is its own +kind of lie, so a run names which roles the command line lost. `--reroute` +inverts the precedence. + +**The transport is injected, not imported.** The retry logic is therefore +testable without a network, and a deployment keeps whatever HTTP client it +already has. The harness owns the policy; the caller owns the socket. + +### 4.2 What a route is made of + +A `Provider` classified failures. The CLI's transport separately assumed one +gateway's completion path, one authentication header and one response envelope. +Neither knew about the other, which had two consequences: "add a vendor" meant +editing the transport function, and changing the classifier changed nothing +about what went on the wire. + +They are four separate parts now, and a **preset** is one name for all four: + +| Part | What it decides | Core default | +|---|---|---| +| request adapter | URL, method, payload keys | `JsonChatRequest()` — POST the endpoint exactly as configured | +| authentication strategy | which header carries the credential | `BearerAuth()`, omitted entirely when there is no key | +| response reader | where the text and the token counts are | `JsonResponseReader()` — conservative; no text path, so it declines rather than guessing | +| failure classifier | what a rejection means for control flow | `GENERIC` — HTTP only | +| model, endpoint | on the route itself | — | +| price reference | what the price table calls this model | the model id | + +The request adapter forwards any option it was not configured to consume +straight into the payload. That is why the agent loop can send `tools=` and +`tool_choice=` without a protocol change — and equally why nothing in +`model_client.py` or `protocols.py` knows what a tool call is. + +The core preset is `generic` and makes no vendor-specific claim. Every other one +is an adapter or a plugin, and `protocols.py` never imports one — it resolves +them **by name**, loading only the name asked for (§9). + +**Which classifier reads a failure is a property of the route.** A gateway that +states its reasons in a body brings the reader for them along with the wire +shape it speaks. The generic preset can only see HTTP, which means it calls a +spend cap `rpm`; that is a documented limit rather than a bug, and +`tests/test_route_conformance.py` asserts it as one. + +**Host detection is a suggestion and nothing else.** `protocols.suggest()` will +look at a hostname and say which preset is probably meant, and the CLI prints +it. It never chooses one. Hosts are proxied, renamed, self-hosted and shared; a +protocol nobody configured is a request sent to a URL nobody chose, and the +first symptom would be a failure the classifier cannot explain. + +**An unknown preset name is loud, not silently substituted.** A live-edited role +map naming one that does not resolve logs a warning and falls back to the +deployment default rather than taking down the readiness report that would show +the operator what they typed. The CLI is stricter: it resolves its default +preset before anything claims work, and refuses with the list of declared names +rather than discovering the problem on the first call. + +### 4.3 Fallback chains + +A role maps to a *chain* of routes, not one route. The whole chain is walked +before any backoff: a parked or capped route is skipped when there is somewhere +else to go, and a connection error moves to the next route rather than sleeping. +The preferred route is `chain[0]`, and everything that *reports* — readiness, +reviewer independence, pricing keys — speaks about it, with a non-preferred +answer recorded as such. + +`CapExhausted` is raised only when a full pass finds every route capped and none +merely rate-limited. That is the difference between "this vendor is out of +budget" and "we are out of budget". + +### 4.4 The retry ladder, and per-endpoint parking + +``` +attempt ──▶ 2xx ────────────▶ record usage + the price applied + │ + └─ error ──▶ classify(status, headers, body) + │ + ├── rpm / transient ──▶ backoff, jittered, THIS worker ──▶ retry + ├── window_cap ───────▶ park this (endpoint, role). Never retry. + ├── terminal_cap ─────▶ park it for much longer. Never retry. + └── non_retryable ────▶ RequestRefused. Stop. +``` + +`retryAfter` is read from the **body as well as the header**, taking whichever +is larger — some vendors put it only in the envelope, and believing the smaller +of the two is how you get limited again immediately. It raises the floor of the +backoff rather than replacing the curve. + +Parks are a per-process, per-`(endpoint, role)` map, extended monotonically and +never shortened. Where a chain offers an alternative the parked route is +skipped; where it is the only route, the worker sleeps out the park rather than +hammering it. Sibling clients created from one parent share the park map, so +what one learned about an endpoint is not re-learned at cost by the next. + +### 4.5 Failure classification + +Six kinds, and the distinctions exist because each demands a different control +flow: `rpm`, `window_cap`, `terminal_cap`, `non_retryable`, `transient`, +`fatal`. Two are never retried under any circumstance and are what §3.3 is +about. + +The generic classifier sees status and headers only — `429` is `rpm`, `401`/ +`403` is a rejected credential, `5xx` is transient, everything else is fatal. +It **cannot** distinguish a burst limit from a spent window, because nothing in +HTTP can. A vendor classifier reads the body: which field names the error, +which categories mean quota, which mean authentication, and which marks +distinguish a short window from a terminal cap. Two traps are documented in the +code because both were paid for: the retry delay lives in the body, and "not +retryable" is not the same fact as "out of budget". + +`outcomes.py` answers a different question and is deliberately not this module: +`providers.py` says what a *provider* answered, `outcomes.py` says what a *gate* +answered and what stopped an item. + +--- + +## 5. The dependency graph + +`depends_on` is a list of strings on the wire, and each string is a **token** +with a grammar. The token says what kind of thing is being waited for, and the +kind is what makes the answer decidable: + +| Token | Target kind | Resolved against | +|---|---|---| +| `W1` | `local_work` | a work row in this project | +| `external:RESOLVER:ID` | `external_reference` | a stored outcome from `RESOLVER` | +| `decision:D9` | `human_decision` | a decision recorded as work here | +| `project:OTHER/W1` | `cross_project_work` | a work row in `OTHER` | +| `?W1` | any of the above, **advisory** | reported, never gating | + +Each edge carries its source, its target kind and identity, required-versus- +advisory, its resolver when external, a resolution state (`unresolved`, +`blocked`, `satisfied`), the evidence for that state, its provenance, and the +graph revision it was written at. **`unresolved` is never a synonym for +satisfied**, and the three states are validated rather than assumed. + +**Parsing a token never raises.** A malformed token — `external:` without a +resolver, `project:` without a slash, a decision naming nothing — becomes an +edge that resolves to `unresolved` carrying the parse message as its evidence. +That is a blocker which explains itself, rather than an exception thrown from +inside the transaction that hands out work. + +An unsatisfied **advisory** edge is reported separately and never enters the +blocking reasons — but it is reported, because an advisory edge that silently +vanished would be indistinguishable from one that was never declared. + +**A required target the graph cannot resolve is a blocker, not an assumption.** +This reverses an earlier rule and the reversal is the whole point. A dependency +absent from the queue used to be treated as satisfied, on the reasoning that +plans reference work tracked elsewhere — which is true, and which made a typo, an +omitted item and a genuine external reference indistinguishable. All three ran +immediately. An external reference is still perfectly legitimate; it just has to +say so and have an answer from a resolver. + +**Dependencies resolve only inside a project** unless the token says otherwise. +An id means one thing here and another there, so crossing the boundary has to be +spelled out. + +**Cycles are named as cycles, and a concrete path is printed.** Two items each +requiring the other were reported as "waiting", forever, one item at a time, +with nothing saying the wait could never end. A set says *these are tangled*; a +path says *how*. Only required local edges can close a cycle — an advisory loop +is not a deadlock, and a target in another project or outside the harness cannot +close a loop inside this one. The cycle set is computed once per claim scan, not +once per candidate. + +**An item with dependencies and no edges is a blocker, not a free pass.** That +is the in-place-upgrade case: "no edges" must never read as "no dependencies", +so it blocks with the tokens named and the rebuild command to run. + +**Local targets are derived on every read; external ones are stored.** A local +edge's state comes straight from the work row, so it cannot go stale and there +is no second copy to disagree with the queue. An external outcome is evidence +obtained by I/O and has to be kept — but it is obtained outside the claim +transaction, because I/O inside the write transaction that hands out work is how +one slow ticket system stalls a fleet. + +### One revision, two checks + +The graph revision moves when the declared graph changes or a resolver reports +something new — **not** when work merely finishes, which is work state. +Re-ingesting an unchanged plan therefore leaves the revision where it was, which +is what stops a routine re-sync invalidating live claims. + +`claim` records the revision it admitted at on the work row. Both executors +re-run **the same `readiness()` call** at the last cheap point before review +spends money and the checkpoint makes anything durable. Two implementations of +"is it ready" would be two answers, and the one that disagreed would be the one +that let ineligible work commit. + +When that second check fails the agent is **not killed** — it has already +reached a safe boundary. The branch is abandoned, the durable resume position is +thrown away (the plan the attempt was briefed with is no longer the plan, so its +diff answers a question nobody is asking), the item returns to `pending` without +consuming an attempt, and a `dependency_invalidated` event records both +revisions. An operator override is scoped to the revision it was granted at, so +the next correction re-blocks the item. + +The edge table is derived from `depends_on`, so it can be dropped and rebuilt; +that is the whole of [`MIGRATION-graph.md`](MIGRATION-graph.md). + +--- + +## 6. State and durability + +### 6.1 Two databases, two fates + +| | `harness.sqlite` | `audit.sqlite` | +|---|---|---| +| Contents | queue, claims, control, projects, settings, attempts, holds, **and the live event view** | events with cost, rollups, baselines | +| Mutability | mutable, migrated in place | **append-only; no UPDATE, no DELETE** | +| If lost | re-sync the plan and carry on | irreplaceable | +| Migrations | rewrites tables in place | additive columns only, forward-only | +| Safe to delete | yes, deliberately | never | + +The queue is a reasonable thing to delete and rebuild from the plan. **Anything +sharing that file shares that fate**, so history does not. The test of whether +this works is a single property: *deleting `harness.sqlite` must not change one +audited answer.* + +There are therefore **two event tables, on purpose**. `store.py` keeps the live +one, in the queue's own file, indexed one index per panel it feeds — it is a +spine for the current view and it shares the queue's fate. `audit.py` keeps the +durable one in its own database, with the columns the live table has no business +carrying: project, item and attempt, tokens, cost, and the price table applied. +A schema mismatch in the live store raises; the audit store instead degrades to +a no-op, because observation must never be the thing that wedges the fleet — +and that is exactly why `/api/audit/health` exists. + +The consequence of degrading rather than raising is that audit writes are +dropped silently, so **nothing else will tell you that history is not being +kept**; a fleet running unaudited looks exactly like one running audited. Check +the health route deliberately. + +The store is read-only in both directions: it never writes to the harness's own +logs, and nothing but the ingester writes to `events`. Append-only is enforced +rather than intended, and the two stores are enforced differently on purpose — +`store.py` by a test that greps its own source for the statements that would +break it, `audit.py` by a test that asserts no public method is named anything +like a mutation, with `thin` present by name so that the test stays honest. A +change that needs to mutate an event has to delete one of those tests first, +which is the point. + +An event's identity is **derived from its content**, not from its position in a +file. Replay, backfill and log rotation therefore collapse onto the same rows +without the ingester tracking offsets, and a repeated identity is a duplicate, +never an amendment — taking the newer one would make history depend on write +order. Baselines are immutable for the same reason: re-recording under an +existing id is refused rather than overwriting. + +### 6.2 Roll up, then thin — never thin alone + +Raw events are retained for a bounded window; daily rollups are written once, +immutable thereafter, and kept forever. **Raw rows are only ever removed after +the rollup covering them exists.** That ordering is the whole discipline; +thinning first is silent data loss that leaves a tidy-looking database and a hole +in the series. + +Two related rules. **Never rewrite history to fit a new schema** — add columns, +leave old rows alone, let readers tolerate absence. A backfill is +indistinguishable from falsification afterwards, and once done once, no number +in the series can be defended. And **store pointers, not payloads**: prompts, +briefs and diffs are large and contain the workload's content, so what is kept +is a hash, a size and a location. The audit layer answers *how much, how often, +how well*, not *what was in it*. + +Events are stamped with their own time rather than the clock that read them. A +revert event uses the revert commit's timestamp and a closed event uses the +platform's `closedAt`, because `time.time()` changes an event's content-derived +identity on every pass and every reconciliation re-records the same revert — +wrong, and unbounded. That bug shipped and was caught by an idempotence test +that failed one run in six, because two `time.time()` calls in one fast run can +return the same float. + +### 6.3 Redaction at the write boundary + +The event store is append-only and the audit store has no mutation surface, +which is exactly *why* redaction runs before the first write: a credential +written into either cannot be deleted afterwards, only rotated. So the filter +sits at `store.append` and `audit.append` — **the only two ways into an events +table** — rather than at the read edge where the plaintext is already on disk. + +That claim is structural, not a convention. One test greps every module for an +insert into the events table and asserts the set of writers is exactly those two +files; another walks the AST and fails any function that writes to that table +without redacting first; a third asserts each store defaults to redacting when +nobody passes a redactor, because a caller added later cannot then forget it. +The one other durable path into the audit database — adopting history out of the +queue's file — goes through the same filter and is named in the test as the +exception it is. + +Two sources of knowledge, and the first is worth far more than the second: +values this deployment knows it holds (exact replacement, no guessing), and +credential *shapes* that are secrets wherever they appear (a bearer header, an +assignment to something named like a key). The replacement is visibly marked, +because a reader must be able to tell "a secret was removed here" from "the +model said nothing", and the event carries a flag saying redaction happened at +all. If redaction itself raises, the event is still written with its payload +dropped — an event that never lands is indistinguishable from a call that never +happened, which is a worse record than a lossy one. + +What is *not* rewritten is as deliberate as what is: the fixed-vocabulary +columns — the kind, the outcome, the error class, the role — are left alone, +because rewriting them would put this module in a position to change a measured +number. + +**This is a reduction in exposure, never a guarantee**, and nothing should +describe it as one. It cannot catch a credential whose shape it does not know +and whose value it was not given. Nothing in it names a vendor: a pattern keyed +to one provider's key prefix would be core knowing what a particular vendor is +called. + +### 6.4 Vocabularies: what a gate answered, and what stopped an item + +A boolean cannot carry the difference between work that is wrong and a gate that +could not run, and those want opposite responses from a person. + +**Five check outcomes**, and exactly one of them is a pass: + +| Outcome | Meaning | +|---|---| +| `pass` | the gate is satisfied | +| `retry` | the gate got no answer, for a reason nothing to do with the item | +| `fail` | the gate ran and the item's work is wrong — the only outcome that is the item's fault | +| `fix_available` | as `fail`, and a mechanical fix is derivable | +| `escalate` | the gate could not run, and no retry and no diff will clear it | + +The satisfied set is spelled out as a set of one, so that adding a sixth outcome +forces a decision about which side of the line it falls on rather than +defaulting to "not a pass". + +**Six dispositions** on the work row, answering *why* it is in the state it is +in: `completed`, `refused` (a gate said no about this work), `crashed` (the +harness broke; look at the harness, not the diff), `withheld` (never attempted, +or discarded through no fault of the item — the item goes back to the queue), +`escalated` (a person must resolve something), and `blocked_by_policy` (§3.7). +Empty means nobody has finished with it yet, which is not a seventh disposition. +Alongside them sit nineteen `reason_kind` tokens, so an API consumer branches on +a token rather than matching on English. + +Two derived sets carry the operational meaning. `DECIDED` is everything except +`withheld` — an attempt that reached a decision is not resumable. `NEEDS_A_PERSON` +is `escalated` and `blocked_by_policy` only; `refused` is deliberately absent, +because a rejected diff is the system working. + +Three distinctions in that vocabulary are load-bearing: + +- `failed` versus `exhausted` — one attempt did not work, versus the harness + will not try again without a human. Without the second, an item that reliably + kills its worker is re-claimed forever, spending real money each cycle and + looking identical to an item that is merely busy. +- `refused` versus `crashed` — a verdict about the work, versus nothing having + been decided at all. +- `blocked_by_policy` versus both — the work was never judged and nothing broke; + the harness declined, on purpose. + +### 6.5 Attempts, holds and budgets + +**Attempts are resumable.** Six fixed stages — `planned`, `implemented`, +`applied`, `checked`, `checkpointed`, `reviewed` — one durable row each, so a +killed worker resumes rather than re-paying for the planner and the implementer. +This is a fixed list, **not a workflow engine**: nothing here composes stages or +takes a graph of them. + +A resumed attempt **continues** the existing one (D11), so `max_attempts` bounds +genuine failures rather than crashes. Recording a stage and being able to resume +*at* it are different things, and the difference is durability: + +- `applied` and `checked` resume at **`implemented`**, because an uncommitted + working tree is not durable. The stored diff is re-applied to a freshly cut + branch, which costs no model call. +- A resumed **checkpoint** is a `git checkout` of the branch, because a commit + is a stronger artefact than any diff; re-applying would conflict with itself. +- A resumed **verdict** is reused rather than re-asked. A model is not + deterministic, so re-asking would make a crash a way to shop for a verdict. +- The **checks** are recorded and never skipped: re-running them is idempotent + and costs no model call, so a resumed attempt runs them again rather than + trusting a result from a tree that may have been rebuilt. + +An attempt is **sealed** once a decision was reached, which is what makes it +non-resumable; a killed worker never decided, so its position survives. A +deliberate `retry` is not a resume — it forgets the position and resets the +counter, because a "retry" that returned an item straight back to `exhausted` +while reporting success is the failure that rule comes from. + +Three durability modes exist because their costs differ: buffer in memory and +lose it on a crash, write at each stage boundary (the default), or additionally +record the *intent* to perform an external effect before it happens. Only the +third closes the window in which a push may have half-landed; the other two do +not close it, and saying so is the honest description of what they buy. + +If the item's brief or dependencies moved while an attempt was in flight, its +durable position is discarded loudly and the attempt restarts from the planner +— a worker briefed from one revision must not be judged against another. + +**A hold is a state of the item.** It is durable, it survives worker death, and +it is answerable from any process — not a projection over events, and not the +coordination plane. Per D12, **a hold suspends the lease and keeps the claim**: +the lease is zeroed but the owner stays on the row, so answering hands the item +back to the worker that asked with its context intact, and `claim` never selects +a held row, so no lease expiry can take it while a person is thinking. + +Four rules bound it. **No model interprets the answer** into a routing decision; +the answer is recorded verbatim and is never a prompt. **No text is injected +into a live terminal** — the process may be at a shell, an approval prompt or +inside another program, and an answer becomes a command. **Being held is not +approval**: a hold that expires returns the item to `blocked`, never to ready. +And every hold has a maximum duration, because the named cost of D12 is that a +worker slot is tied up for the whole of it — which is why holding on planner +ambiguity is opt-in, and why an unwatched fleet would rather fail an item in +seconds. + +**Budgets are the deployment's ceiling on one item** — wall-clock and spend — +and default to unlimited, so enabling them changes no behaviour until someone +sets one. They are checked at boundaries that already exist rather than from a +timer, because a budget stop must never kill work mid-stage: doing so destroys +the context and leaves a half-finished worktree. Spend accrues as it happens +rather than being reconstructed afterwards, so an item that blows its ceiling on +the implementer never reaches the reviewer, and the total is carried across +attempts because the ceiling bounds the item rather than one try at it. + +A call that reports no usage is counted as **unpriced, not free**, and one +unpriced call makes the spend total unmeasurable. A declared spend ceiling over +an unmeasurable total is then reported as **unenforceable** rather than treated +as satisfied — unknown cost is not zero cost, and a ceiling that cannot be +checked is not a ceiling that was met. It is said once, as a fact, not as a +warning nobody reads. + +Exceeding one is **not** a provider cap. It lands the item in `blocked` with the +ceiling named, consumes no attempt, and **never parks an endpoint** — parking a +shared endpoint because one item was expensive is exactly the conflation the +vocabulary exists to prevent. The two facts even have separate event names: +`budget_exhausted` is a *provider* saying our account is out, and belongs to the +never-retry set; `budget_exceeded` is *this deployment* saying one item has had +enough. + +### 6.6 The only honest quality metric + +Everything the pipeline produces is a proxy. A reviewer approved it; the checks +passed. None of that says the change was any *good* — only what happens to it +afterwards does, and that happens outside the harness. + +| Metric | What it actually measures | +|---|---| +| Review approval rate | whether a reviewer agreed | +| Check pass rate | whether the cheap gate works | +| Merge rate | whether a human accepted it | +| **Revert rate** | whether accepting it was right | + +So merges, closures and reverts are fetched from the platform and recorded, with +three properties. **Two facts, never one that changes its mind** — a PR merged +today and reverted next week produces `pr_merged` then `pr_reverted`, in order, +both true when recorded; rewriting the merge would make history depend on when +you looked. **Stamped with their own time** (§6.2). And **unattributed outcomes +are skipped, not counted** — repositories are full of pull requests the harness +never made, and an outcome belonging to no item inflates every rate it appears +in. + +Approval rate and revert rate come apart exactly when it matters. A harness +tracking only the first reports improving quality right up until someone looks +at the repository. + +### 6.7 The coordination plane + +A third durable store, separate again, exists for a different kind of fact: what +participants *said*. It is designed around one distinction — **conversation, +proposals, commands and state are four different things, and a message never +becomes queue state merely because an agent asserted it.** + +- **The ledger** is append-only with no update and no delete path at all, + per-room gapless sequencing assigned inside the write transaction, a + content-chained digest per message, and a closed vocabulary of message types. + It scans for secrets *before* accepting, because permanent retention means an + accidentally posted credential cannot be removed afterwards — only hidden + behind an append-only access restriction. +- **The command service** is the single deterministic door for state change, + whoever the caller is. A proposal pins the graph revision, item state, owner + and attempt it expects; a stale one is rejected and the rejection is appended + to the room it came from. Applying an accepted command twice has the effect of + applying it once, which is what makes crash recovery possible. +- **The oversight actor** is one per project, selected by a lease with a fencing + generation so a restart cannot produce two authorities that both believe they + are current — and so a deposed coordinator's in-flight proposal is refused + rather than applied. It holds a read-only view: no queue handle, no database, + no platform credential, and no resume tokens. **Its worst possible output is a + bad suggestion.** It may not mark a dependency complete without evidence, + override a check or a verdict, retry a terminal cap, fabricate ownership, edit + a message, or pause a project because its own route is unavailable. + +Absence is safe by construction: if the coordinator is unavailable, unresolved +work stays blocked and everything whose deterministic gates are satisfied keeps +running. Worker-side use is wrapped so that a ledger failure cannot fail an item. + +**Status, in one clause:** the ledger, rooms, command service and oversight +actor are implemented and tested but **not wired** — nothing in the API or the +CLI constructs a ledger, so in a running deployment no worker speaks into the +plane and no coordinator reads from it. `docs/COORDINATION-PLANE.md` still +describes these four as proposed; that is stale, and the code is what counts. +The typed work graph from the same document (§5 here) is built and live. + +--- + +## 7. Project isolation + +Two projects both have a `T1`. Before project scoping they were **the same +row** — loading a second project did not fail to isolate, it overwrote. + +Items are keyed `(project_id, item_id)`. Claims and dependencies resolve only +within a project unless a token says otherwise (§5). Each project has its own +control state and its own concurrency budget, and `fleet.py` gives each **one +worker pool**, so no project starves another and a failed model route in one +cannot stall the rest. + +``` + one harness process + ┌───────────────────────┬───────────────────────┐ + │ project: alpha │ project: beta │ + │ control: running │ control: stopped │ + │ workers: 3 │ workers: 0 │ + │ T1, T2, T3… │ T1, T2… │ + └───────────────────────┴───────────────────────┘ + never reaches across +``` + +Sharing threads across projects is deliberately not an option: it reintroduces +starvation through the back door, and the fair thing to share is nothing. Each +worker also carries its own stop signal rather than one per pool, because with +a single shared event the only available answers were "keep every worker" and +"stop the project". + +**Nothing resumes on its own.** Boot sets every project to `stopped` and records +what it *was* doing, so a project deliberately drained before a restart does not +come back looking identical to one that was running happily. A project registers +itself `stopped` too, so registering one cannot begin spending — and an +**unregistered** project id reads as `stopped` rather than running, because +defaulting the other way would mean a typo in a project id silently granted +claims. Only an explicit start creates workers. + +Four control states, and **none of them kills anything**. `paused` and +`draining` are identical to a worker; the difference between them is recorded +operator intent, which is exactly the thing a person restarting a service needs +and a worker does not. `stopped` is not a third pause: pausing instructs a +running fleet, and stopped means there is no fleet to instruct. Stopping never +interrupts work in flight. + +A worker that dies takes its claims with it — and they are released as +**`failed`, not requeued**. The item that killed a worker is the likeliest item +to kill the next one, and a silent requeue turns that into a crash loop that +spends money. + +--- + +## 8. The API surface + +`api.py` + `schemas.py` serve a documented OpenAPI document with Swagger UI, and +it is treated as a contract rather than as a debugging aid: + +- **Every route names a response model.** A route returning a bare dict produces + a schema of `{}` — valid, and useless to anyone generating a client. +- **Every field carries a description. The schema is the documentation.** +- **The docs need no token; the data does.** Requiring a credential to read a + schema makes an API undiscoverable for no benefit. +- Behind a proxy, the root path must be set, or the schema advertises URLs the + client cannot call. + +Tests assert these properties, not just status codes. + +Two service modes, and neither starts anything on its own. Without a session +host, `serve` is **monitoring-only** and starting a project is refused rather +than setting a flag no worker acts on. With one, the same API gains a worker +pool its start action can use — and still nothing runs until someone starts a +project. + +### Why there is no GUI here + +The GUI belongs to the session host. It already owns tabs, token +authentication, push notifications, a mobile story and the PTY sessions the +agents run in. A web UI in this repository would mean a second URL, a second +login, no notifications and no phone — worse, for the same work. + +This is not an aesthetic position; it is the same reasoning as everywhere else +in this document. The harness owns the decisions and the record. It serves JSON, +and the host renders it. + +--- + +## 9. Extension points + +"Add a vendor without editing core" is only true if the ones this repository +ships are added the same way. They are. + +**Route presets, by installed metadata.** `protocols.py` resolves a preset by +name, loading only the name asked for, in this order: built in this process via +`register()`; named in configuration through `HARNESS_ROUTE_PRESETS`; published +by an installed distribution through an `agent_harness.route_presets` entry +point. This distribution's own presets are reached by the third door — if the +shipped ones took a shortcut through an import, "addable without editing core" +would be true only for the vendors we happen to ship. + +```python +# somepackage/presets.py — in anyone's package +from agent_harness.protocols import BearerAuth, JsonChatRequest, JsonResponseReader, RoutePreset +from agent_harness.providers import VendorEnvelopeProvider + +PRESET = RoutePreset( + name="somevendor", + request=JsonChatRequest(path="/v2/generate", model_key="model_id", messages_key="turns"), + auth=BearerAuth(header="x-api-key", scheme=""), # no scheme word + reader=JsonResponseReader(text_paths=("result.reply",), usage_key="counters"), + classifier=VendorEnvelopeProvider(vendor_field="problem", quota_categories=("budget",)), +) +``` + +```toml +# ...declared once, in that package's pyproject +[project.entry-points."agent_harness.route_presets"] +somevendor = "somepackage.presets:PRESET" +``` + +```json +// ...and named by a route. PUT /api/roles +{"roles": {"implementer": {"model": "m", "endpoint": "https://…", "preset": "somevendor"}}} +``` + +Nothing in `model_client.py`, `providers.py` or `protocols.py` changes for that, +and the conformance suite runs a preset registered from outside to keep it that +way. + +**Dependency resolvers, the same door.** A resolver that knows one tracker's +format lives in `adapters/` and is declared under +`agent_harness.dependency_resolvers`. A dotted module path written into +`graph.py` would still be core knowing what a particular tracker is called. + +**Adapters generally.** Log readers, telemetry export and the agent loop are all +opt-in and lazily loaded, and nothing in core imports any of them. Telemetry is +**export-only**: it projects the event stream outward, nothing reads back, and +the event store stays the source of truth. + +**Checks are argv the deployment supplies.** There is deliberately **no +registration mechanism for third-party gates** — that decision is open, and a +test fails if `outcomes.py` ever grows a registry, so it cannot be settled by +accident. + +**What is not an extension point.** The gates themselves. A preset can change +what a failure means; it cannot change whether checks run before the reviewer, +whether an unreviewed change can present itself as reviewed, or whether a spend +cap is retried. + +--- + +## 10. Known limits of this design + +Named here rather than left for a reader to discover. + +- **The generic classifier cannot see a spend cap.** It calls one `rpm`, + because nothing in HTTP distinguishes them. A deployment on the generic preset + gets §3.3's protection only as far as HTTP status codes reach. +- **The command guard is screening, not isolation** (§3.7), and cannot read + inside `sh -c`. +- **Redaction is a reduction in exposure, not a guarantee** (§6.3). +- **A hold ties up a worker for its whole duration** (§6.5), which is why the + maximum is not decoration and why holding is opt-in. +- **Only one dependency can be stacked on**, and the ones that were not are + named rather than hidden (§2). +- **The coordination plane is one-way, and unwired** (§6.7). Agent-to-agent + messages and human participation over the API are designed and not built, and + two modules currently spell an item's room differently — harmless only + because nothing in production constructs a ledger. +- **The single-shot call is the known shape defect** (§2.3), and is why #195 + exists. + +Where to go next: [`AGENTS.md`](../AGENTS.md) for the binding rules, +[`STATUS.md`](STATUS.md) for what is built and what has been proven, +[`USAGE.md`](USAGE.md) for a worked example, [`DEPLOYMENT.md`](DEPLOYMENT.md) +for running it as a service, and [`evidence/`](evidence/) for the packages that +each name their own blind spots.