Architecture¶
Small on purpose. The whole runtime is a supervisor, a boundary, a ledger and a verifier — plus rendering and analysis, which are separable. There are no daemons, no database, no background workers and no network service beyond a loopback proxy that lives for one run.
docket run -- <agent>
│
├── supervisor.py owns the run: spawn, caps, snapshots, receipt, exit code
│ ├── proxy.py the boundary: policy checks, usage→cost, event recording
│ ├── fsdiff.py before/after filesystem digest snapshots and diffs
│ ├── snapshot.py content snapshots and confined restore (opt-in rollback)
│ └── receipt.py assembles the receipt from the ledger
│
├── ledger.py append-only hash-chained event log (the source of truth)
│ ├── canon.py canonical JSON, digests, RFC 6962 Merkle, USD decimals
│ ├── events.py event vocabulary + request fingerprinting (loop detection)
│ └── pricing.py price table, cost meter, recomputation for verification
│
├── policy.py policy document, caps, allow/deny, digests, limits disclosure
├── explain.py deterministic findings over a ledger
├── verify.py offline verification of receipts and ledgers
├── report.py terminal + markdown rendering, receipt diffing
├── sdk.py in-process recording for Python agents
└── cli.py argument parsing, exit codes, wiring
Invariants, and which module owns each¶
| invariant | owned by | enforced by |
|---|---|---|
| A hashed structure contains no floats (integers, or decimal strings) | canon.py |
test_canon.py + both implementations in verify-js/ |
| Canonical bytes are language-independent (sorted keys, no whitespace, literal UTF-8) | canon.py |
spec/vectors/canonical.json |
Ledger lines are canonical JSON + \n, so file_sha256 is reproducible elsewhere |
ledger.py |
test_ledger.py, spec/vectors/ledger.json |
| A modified, reordered, truncated or extended ledger fails at the first affected event | ledger.py chain check + verify.py |
test_ledger.py, test_receipt_verify.py |
| Downstream comparisons use recomputed hashes, never stored ones | verify.py |
test_verification_fails_when_the_ledger_is_edited |
A check that cannot run is SKIP with a reason, never a pass |
verify.py |
test_receipt_verify.py |
| Cost is recomputable from raw token counts and the embedded price table | pricing.py |
test_pricing.py, test_vectors.py |
| Price families match in file order (first match wins) | pricing.py |
test_pricing.py (specificity ordering) |
Verify fails if limits_disclosure is absent |
verify.py |
test_receipt_verify.py |
| The effective policy (after flags) is what the receipt embeds | supervisor.py, receipt.py |
test_supervisor.py |
Usage marked estimated is never presented as metered |
pricing.py, receipt.py, report.py |
test_pricing.py, test_receipt_verify.py |
| A restore writes only inside the tree it was pointed at, and only where the record says | snapshot.py |
test_snapshot_confinement.py |
| A file changed after the run is refused, not overwritten | snapshot.py |
test_snapshot_restore.py |
| A snapshot that cannot cover the tree is refused before the agent starts | supervisor.py, snapshot.py |
test_snapshot_restore.py |
| The boundary uses the cache it was given — an enabled-but-empty cache is not "no cache" | proxy.py, cache.py |
test_cache.py (the regression test passes a cache as an argument, which is how the defect it pins was found) |
Only a whole, non-injected 2xx response is ever stored, and a hit names the response it replays |
cache.py |
test_cache.py; the verifier's cache-hit-origins and cache-savings checks, in both implementations. The rule has a normative half: RFC 6585 §4 — and the same sentence for 428, 431 and 511 — says a 429 "MUST NOT be stored by a cache", and one of the tests that pins this row asserts exactly that a 429 is refused with a reason (standards) |
| A cache hit is not a model call: no cap, total or call count moves, and the saving is reported beside the spend | proxy.py, receipt.py |
test_cache.py, test_receipt_verify.py, the game day's cache-brake phase |
Each of those has a test because each of them is a sentence in the README that would otherwise be marketing.
The run, step by step¶
supervisor.run()creates the docket directory, opens a ledger, writessession.start(with argv, cwd, host details and the docket number) andpolicy.applied(with the policy document and its digest).- It starts the boundary proxy on an ephemeral loopback port and computes the environment for
the child: provider base-URL variables,
DOCKET_PROXY_URL, session metadata. - It snapshots the watched filesystem — digests always, contents too when
--snapshotis on, refusing the run up front if the tree does not fit the budget — writesproc.spawn, and launches the child in its own process group (start_new_session=True) so a breach can terminate the whole subtree. - While the child runs, the supervisor polls for exit, watches the wall-clock cap, and checks
a breach signal set by the proxy. On breach: ask the boundary to refuse further calls,
SIGTERMthe group, waitgrace_seconds,SIGKILL. - The proxy, for every model call: parses the request, decides policy (model allow/deny),
checks the caps, records
model.request, forwards, streams the response back, extracts usage (reported or estimated), accrues cost, recordsmodel.responsewith latency and bytes, and emits warnings atwarn_at. - After exit: snapshot the filesystem again, diff, emit
fs.changeevents and anypolicy.violationfordeny_writehits; writeproc.exitandsession.end. receipt.pybuilds the receipt: totals from the meter, per-call usage rows with Merkle inclusion paths, the price table (embedded), findings fromexplain.py,limits_disclosurefrom the policy, and the ledger roots. Optionally seal (HMAC) and sign (Ed25519).- The receipt is written atomically to
.docket/receipts/<number>.receipt.json;.docket/latestand.docket/latest.receipt.jsonpoint at the newest run. The CLI prints a summary and exits0, or42if a cap fired. - If the run breached a cap and
--rollback-on-breachwas given, the watched tree is restored from the content snapshot after the child has exited, and the outcome — counts, paths, refusals — is written to the ledger asx-docket.rollback-applied(orx-docket.rollback-refusedwhen every path was declined, because those are two outcomes and a report that runs them together is a false claim) and folded intoops.rollbackin the receipt.docket restoreperforms the same restore on demand, months later, from the receipt and ledger alone. - A separate
docket restore --apply— the same restore, months later, by somebody who may never have run the agent — writes its own record to.docket/restores/<utc-stamp>-<docket-number>.json(docket/restore/v1): what it planned, what it did, what it refused, and whether--forcewas used. It never edits the run's ledger or receipt: those are historical observations, and appending to them afterwards would break the receipt's own agreement with the ledger it cites.
Why the ledger is the source of truth¶
Everything downstream — totals, findings, the receipt, the report — is derived from ledger
events rather than accumulated in parallel. That is why docket explain --ledger x can
reproduce the numbers in a receipt months later, and why the receipt's appendix can be
re-derived and checked rather than believed.
It also means a new analysis rule is a pure function of an existing record: no migration, no re-instrumentation, no "we only started tracking that last quarter".
Threading and failure behaviour¶
- The proxy is a
ThreadingHTTPServeron loopback, one thread per request. Requests from a single agent are usually sequential; retries and parallel tool calls are not assumed away. - One ledger, one writer at a time.
Ledger.appendtakes an instance lock for the whole step — sequence number,prevlink, digest, write — so the file is always a contiguous chain in sequence order no matter how many request threads append at once. Without it, a parallel burst produced a file a verifier reports as broken: the tool accusing an honest run. The lock is held for the append, not for the upstream call, so a slow provider does not serialise the writers. - The caps are enforced at admission: with n requests already in flight when a cap is breached, at most one paid call per in-flight request is recorded before the refusal takes effect. The receipt says what happened; it does not claim the overshoot was prevented. ADR 0005 has the full list of concurrency invariants (contiguous chain, conserved cost, a limiter that admits exactly its budget, a breaker that counts each failure once).
- Ledger writes
flush()andfsync()per line. A crash mid-run leaves a valid prefix of the chain, anddocket verifyon a truncated ledger reports the count mismatch rather than pretending completeness. - If the child ignores
SIGTERM, the group isSIGKILLed aftergrace_seconds; the receipt recordsinterruptedand the cap that caused it. Exit code42is returned either way. - Nothing in the run path can block on the network: the proxy connects to the provider; if the
provider is unreachable the agent sees the ordinary connection error and Docket records it as
model.error. - Docket never makes an outbound connection of its own. There is no telemetry, no update check, no remote price fetch — a test asserts the only client the core can construct targets loopback.
Extending¶
- A new provider: add a prefix→upstream mapping and a usage extractor in
proxy.py; the receipt format does not change. - A new analysis rule: add it to
explain.pywithseverity,confidence,evidence,usd_impact,remedy. Deterministic rules must be derivable from events alone. - A new verifier language: implement §8 of the spec against
spec/vectors/; no need to read this codebase. - A new event type: add it to
events.py. Unknown types are preserved by verifiers, so extensions do not break older readers — but anything that changes a hash input is a format change (see../CONTRIBUTING.md).