A ticket is what an intent may do, carrying its own chain of custody. A
subagent spawned under one can only narrow its caveats, never loosen them —
the same "humans widen, machines tighten" rule (invariant 2) applied to scope
rather than to permission mode
(ADR-0039).
flowchart TD
typed["a typed line
PromptTyped, depth 0"] --> intent["Intent
the why"]
scope(["--scope / /scope
KEY=VALUE"]) --> ticket["Ticket
caveats: tools, paths, hosts, calls, until"]
task(["task tool
delegation"]) -->|"attenuate() only adds"| ticket
ticket --> gate["authorize()
pre_tool, one step before permissions"]
gate -->|"refuse"| refuse["ScopeRefused —
an error tool result, caveat named"]
gate -->|"allow"| run["the call runs"]
intent --> receipt[("Receipts
signed, hash-chained JSONL")]
ticket --> receipt
refuse --> receipt
run --> receipt
1Intent and the ticket
broker/intent.py broker/ticket.py broker/caveats.py
Look for: Intents Ticket attenuate
verify_chain Caveat parse_scope
Intents is the bus subscriber that opens an intent from a typed
line — the same "the boundary is the subscription" shape as
learning's own subscribers: it reads exactly
one event kind, PromptTyped at depth 0, and nothing a subagent,
a tool result or an @path body produces ever reaches it
(MEM-8, MEM-9). parse_scope turns human-typed
KEY=VALUE pairs — tools=, paths=,
hosts=, calls=, until= — into
Caveats, resolving a relative until=10m to an
absolute instant right away, so a ticket's expiry never depends on when
someone later checks it.
attenuate() is the only way to get a child ticket. A
list-shaped caveat can only gain items; calls and
until can only tighten — so "merge" means "narrow" for the
two of them.
verify_chain() walks the parent links and fails the
moment a child's caveats are not at least as strict as its parent's. It
compares what each kind means (a list superset, or a tighter
limit), never raw equality — a naive set comparison flags even an honest
attenuation as a forgery, since attenuate() legitimately
rewrites a caveat's value string (tools=a +
tools=b → tools=a,b). A hypothesis property
test caught that the first time round.
2The veto, one step before permissions
broker/guard.py broker/authorize.py
Look for: TicketGuard check narrowed
authorize Refusal
authorize() is pure and property-tested, like
permissions.decide() on the Core tour: the same ticket, the same
call, always refuses or allows the same way. tools/execute.py's
pipeline is validate → pre_tool hooks → ticket → permission → run →
spill — the ticket check runs first, on the same resolved
Subject the permission engine reuses, so a refused call never
even reaches the mode's own rules. TicketGuard is the mutable
adapter execute() actually calls; it matches
tools/base.py's Broker Protocol structurally, so
Core never imports this file, only its shape.
- A
paths or hosts caveat can only check a
tool whose subject resolves to a path or a URL. shell, and
any command tool not marked read-only, has neither — it is refused
outright unless named in tools=. That is the deliberate,
visible cost of scoping to files or hosts: a paths=-only
ticket does not, by itself, stop a URL-shaped call from reaching an
outside host — that needs hosts=.
- A call the ticket allows counts toward
calls=; a refused
one does not, since it never happened as far as the ticket's own budget
is concerned.
narrowed() is task's delegation seam:
agents/spawn.py calls it with the agent definition's own
tools: and any model-given scope argument, both
folded in through attenuate() — never a drop.
3The signed receipt
broker/receipt.py
Look for: Receipts append sign
verify load_or_create_key Break
Every decision the broker makes — intent, ticket, delegation, refusal,
permission — is one line in an append-only JSONL log at
.edgar/sessions/<id>/receipt.jsonl. Each line signs its
own prev/kind/data with HMAC-SHA256;
prev is the SHA-256 of the previous line's exact text, so
tampering with an earlier line breaks every hash after it, and tampering
with a line's own content breaks its own signature. verify()
checks both, in order, and stops at the first Break.
- The key lives at
~/.edgar/receipt.key, 32 random bytes
made once, mode 0600 from the first byte (on Windows, which has no modes,
the user profile's own permissions guard it) — a hard-layer credential path the model's own
tools can never read, the same protection permissions.matcher
already gives real credentials.
Receipts is a bus subscriber, the same shape as
Intents: one line per depth-0 PromptTyped
(intent), depth>0 TurnStarted
(delegate), ScopeRefused (refuse),
and PermissionResolved (permission); the ticket
itself is written once, directly, when a session starts one.
4edgar receipt, and the wiring
broker/cli.py broker/__init__.py
Look for: command attach from_scope
describe
command is edgar receipt [ID] [--refused] [--verify]'s
whole implementation: with no flag it tells the story, one line per entry;
--refused narrows to what was refused; --verify
replays the chain and exits 1 at the first break. attach() is
the one function that wires both subscribers onto a real bus, called from
cli/setup.py's _broker() — the fourth seam reached
by name through import_module, the same way
cli/setup.py reaches escalation and the controller, so nothing
outside broker/ ever imports it directly (NFR-12).
from_scope() and describe() are what
--scope and /scope actually call — building a
live ticket from typed KEY=VALUE pairs, and summarising one
for a bare /scope.
[broker] enabled = false (config/schema.py,
default true) turns off both the veto and the receipt
through this same seam. A setting that read fine and did nothing would be
exactly the hidden behaviour this harness promises not to have.
Sizes
Lines of code: blank lines and comments do not count, docstrings do.
just loc prints the current totals.
| File | Lines of code | What it is |
broker/intent.py | ~27 | Opens an intent from a typed line, and nothing else |
broker/caveats.py | ~37 | The five caveat kinds, and parsing KEY=VALUE |
broker/ticket.py | ~57 | Attenuation, and chain verification |
broker/guard.py | ~35 | The mutable adapter the pipeline actually calls |
broker/authorize.py | ~72 | The pure veto, property-tested |
broker/receipt.py | ~78 | The hash-chained, signed log |
broker/cli.py | ~33 | edgar receipt's whole implementation |
broker/__init__.py | ~26 | The one seam cli/setup.py reaches by name |
Source: just loc, which counts with
tests/support/budget.py. Rounded; the tour's test fails if a figure drifts
more than 25 lines of code from the code.