The Ledger
The Ledger is the searchable history of everything agents did in an org: every action, every state
change, every deed and every admin decision. The Console’s Actions and Deeds screens read it, a
security lead searches it after an incident, and an auditor exports it and checks it with
deedglass verify without trusting Deedglass.
Code: internal/ledger (S06). It has nothing to do with deedglass.ledger, the table S17 creates
in a customer’s database for L2 witnessing.
1. What the Ledger records
Section titled “1. What the Ledger records”| Record | Table | Changes? | Holds |
|---|---|---|---|
| Action | actions |
its state moves along the lifecycle; payloads can be pruned | who, which tool, the decision, the execution, the verdict, digests, and the payloads (request, result, witness_detail) until retention removes them |
| Action event | action_events |
append-only | one row per state change (seq, from, to, at, actor) plus short codes (reason, rule_id, error_code, level, verdict, approval_id, detail_digest), at most 2 KiB, never payloads |
| Deed | deeds |
append-only | the sealed record (DEED_SPEC.md): digests, never payloads, hash-chained per org |
| Admin event | admin_events |
append-only | one administrative change (who, what, which object, why, at most 280 characters of reason, a 16 KiB data object), and the admin deed that seals it |
One write path: nothing in Deedglass writes action_events, admin_events or deeds except
internal/ledger. A state change, its event, its deed and its event notification commit in one
transaction, or not at all.
2. The lifecycle
Section titled “2. The lifecycle”The edges are contract §5, internal/gate/state.go, and a database trigger that mirrors it.
stateDiagram-v2 [*] --> received received --> authenticated authenticated --> evaluated evaluated --> denied evaluated --> pending_approval evaluated --> executing pending_approval --> executing pending_approval --> denied pending_approval --> expired pending_approval --> cancelled executing --> executed executing --> failed executed --> witnessing witnessing --> verified witnessing --> mismatch witnessing --> unverifiable denied --> sealed expired --> sealed cancelled --> sealed failed --> sealed verified --> sealed mismatch --> sealed unverifiable --> sealed sealed --> [*]An action is sealed only from one of the seven final states (gate.State.Sealable), and only by
ledger.SealAction, which seals its deed in the same statement that moves it to sealed. When
Deedglass can’t know whether a tool ran, the action goes executing → executed with
execution.status = unknown, then witnessing → unverifiable. A later verdict never edits a deed:
it is sealed as an amendment deed.
3. Querying actions
Section titled “3. Querying actions”GET /v1/actions (and deedglass actions list) returns the newest first, ordered by
(created_at, id) descending.
| Parameter | Meaning | Example |
|---|---|---|
agent |
an agent id | agt_01j9z9b1d3f5h7k9m1p3r5t7v9 |
tool |
exact, or a glob with * (at most three; _ and % are literal) |
stripe.*, *.execute |
state |
a comma list of states | pending_approval |
decision |
allow, deny, require_approval |
deny |
verdict |
VERIFIED, MISMATCH, UNVERIFIABLE |
MISMATCH,UNVERIFIABLE |
level |
L0, L1, L2 |
L2 |
on_behalf_of |
the exact reference the agent sent | u_4821 |
created_at[gte], created_at[lt] |
RFC 3339 | 2026-10-02T00:00:00Z |
q |
2 to 64 characters to find in the tool name or the error code | refund |
shadow_differs |
only true: actions whose shadow policy decided, and decided differently from the enforced decision (S26) |
true |
limit |
1–100, default 25 | 100 |
cursor |
next_cursor from the previous page |
Rules:
- Filters combine with AND. A parameter this list doesn’t know is refused (400
validation_failed), so a typo never widens a query. - The cursor is opaque. It holds the last row’s
created_atand id and a hash of the filters it was issued with. Reusing it with different filters is 400validation_failedoncursor: “This cursor belongs to a different query.” Cursors carry no org; row-level security scopes every query to yours. - Rows added while you page appear only on a fresh first page; they never shift later pages.
- An id from another org is 404
not_found, exactly like an id that doesn’t exist.
curl -s -b cookies.txt 'http://127.0.0.1:8080/v1/actions?verdict=MISMATCH&limit=2'deedglass actions list --verdict MISMATCH --limit 2deedglass actions show act_01j9zt3a5c7e9g1j3m5p7r9t1vGET /v1/actions/{id} adds request, result and witness.detail (null after pruning), up to
200 events, the deeds about the action (its deed and any amendments) and approval (null
until S22).
The routes take a Console session: sign in (docs/IDENTITY.md), and send the session cookie
(cookies.txt above is a curl cookie jar from POST /v1/auth/callback). API keys arrive in S08.
S06’s loopback-only dev principal (Deedglass-Dev-Org) was removed in S07.
4. Deeds and the chain
Section titled “4. Deeds and the chain”Every org has one chain: deed seq 1, 2, 3… with no gaps, each deed’s prev_hash the body_hash
of the one before (seq 1 links to the org’s genesis value). The database refuses a fork, a gap and
any UPDATE or DELETE. DEED_SPEC.md has the format.
GET /v1/deeds?from_seq=&to_seq=&kind=&action=&order=asc|desclists envelopes, oldest first.GET /v1/deeds/{id}returns the stored canonical bytes verbatim, withETag= thebody_hashandCache-Control: private, max-age=31536000, immutable. WithAccept: application/coseit returns a COSE_Sign1 signed statement (not an RFC 9942 transparency-service object).deedglass deeds show 812takes an id or a seq.
5. Checking the chain
Section titled “5. Checking the chain”GET /v1/ledger/check and deedglass ledger check stream the org’s deeds in seq order through
the same verifier deedglass verify uses, with the server’s own key set: hashes, seals, links and
seq continuity.
$ deedglass ledger check✓ Chain intact · 1,204 deeds · seq 1 → 1,204 · 2 seal keys · 0.31 s head seq 1,204 · sha256:5e0b…a91c · keep this somewhere outside Deedglassstatusisintact,broken(withbreak: seq, deed, code, message, next) orincomplete(the 30 s budget or the 2,000,000-deed limit was reached;next_from_seqsays where to resume). A broken chain is a result, not an error, and it is logged at ERROR.- A range that doesn’t start at 1 is anchored on the stored
body_hashof the deed before it. - Keep the
headsomewhere outside Deedglass (a ticket, a log, a different system). A check inside one database can’t see deeds deleted from the end of the chain; comparing today’s head with one you kept can.
Exit codes: 0 intact, 1 broken, 2 incomplete.
6. Exports
Section titled “6. Exports”GET /v1/deeds/export and deedglass deeds export write an export bundle v1
(BUNDLE_FORMAT.md): README.txt, keys.json, deeds.jsonl, manifest.json
and manifest.seal.json, as a deterministic tar.gz.
- One
REPEATABLE READ READ ONLYsnapshot for the whole export: pass 1 counts the deeds and their bytes, pass 2 streams them. Memory stays flat; writers are never blocked. - Choose deeds by
from_seq/to_seqor byissued_at[gte]/issued_at[lt], at most 1,000,000 at a time. An empty range is 404 with where the chain ends. - The same range exported twice gives the same bytes, apart from
created_at(a test pins it). - If the client disconnects, the export stops and rolls back; the partial file fails
deedglass verifywithbundle_invalid. --format jsonlwrites the deeds alone, one stored envelope per line.
$ deedglass deeds export --out acme-oct.tar.gz◐ Exporting deeds 1 → 1,204 for org_01j9z8m2…✓ 1,204 deeds · 2.7 MB · manifest sealed by sk_01j9zr8k… file acme-oct.tar.gz next deedglass verify acme-oct.tar.gz --keys http://127.0.0.1:8080/.well-known/deedglass/keys.jsonPin the issuer’s keys when you verify (--keys <issuer>/.well-known/deedglass/keys.json): the
bundle’s own keys.json shows nothing changed since export, not who sealed it.
7. Admin deeds
Section titled “7. Admin deeds”Every administrative change is an admin deed, sealed through ledger.RecordAdmin with its
admin_events row. The names are a catalogue in internal/ledger/admin_actions.go
(<object>.<past verb>, matching ^[a-z_]+\.[a-z_]+$); each session adds its own line, and
RecordAdmin refuses a name that isn’t there.
| Action | Session |
|---|---|
ledger.pruned |
S06 |
org.created, org.updated, member.invited, member.joined, member.role_changed, member.removed, login_link.issued |
S07 |
agent.created, agent.updated, agent.suspended, agent.resumed, agent.stopped, agent.started, org.stopped_all, org.started_all, api_key.created, api_key.rolled, api_key.revoked |
S08 |
agent.intent_key_added, agent.intent_key_removed, agent.intent_mode_changed |
S09 |
policy.activated, policy.deactivated |
S12 |
connector.credentials_updated |
S16 |
decoder.slot_created, decoder.slot_dropped |
S20 |
approval.decided, approval.countersigned, webhook.created, webhook.deleted, webhook.disabled |
S22 |
anomaly.resolved, insights.settings_changed |
S27 |
suggestion.accepted, suggestion.rejected |
S28 |
connector.disabled |
S30 |
connector.tool_hidden (actor {system, deedglass}; data connector, tool, reason description_flagged or definition_changed, kind, field, excerpt of at most 40 characters, digest, pinned) |
S34 |
file.sealed (target {org, org_…}; the label in admin.reason; data sha256, bytes, media_type, label; SEALING_FILES.md) |
S38 |
Only the digest of the event’s data enters the deed; the data stays in admin_events.data.
8. Events
Section titled “8. Events”Every write publishes on the Postgres channel deedglass_events, delivered at COMMIT (a rolled-back
seal never announces itself). S24 turns these into SSE, S22 into webhooks.
| Transition into | Event |
|---|---|
received |
action.created |
denied, pending_approval, executing (from evaluated) |
action.decided |
executed, failed |
action.executed |
verified, mismatch, unverifiable |
action.witnessed |
sealed, every admin deed and every amendment |
deed.sealed |
One payload shape, every key present (null when it doesn’t apply), at most 1 KiB, ids and states
only:
{"v":1,"event":"evt_01j9zt3q5v7x9z1b3d5f7h9k1m","type":"deed.sealed","org":"org_01j9z8m2k4p6r8t0v2x4z6b8d0","id":"deed_01j9zt3q5s7v9w1y3a5c7e9g1j","seq":812,"action":"act_01j9zt3a5c7e9g1j3m5p7r9t1v","state":"sealed","deed":"deed_01j9zt3q5s7v9w1y3a5c7e9g1j","at":"2026-10-02T10:14:03.512Z"}Nobody calls pg_notify('deedglass_events', …) directly; ledger.Publish is the one door.
9. When the chain is broken
Section titled “9. When the chain is broken”Deeds are append-only for every Deedglass role, so a broken chain means someone with database superuser rights (or with the files) changed the database underneath Deedglass. Treat it as an incident:
- Stop writes. Stop the Deedglass servers, so nothing new is sealed onto a broken chain.
- Snapshot the database as it is now (
pg_dump, or a volume snapshot), before anything is repaired or restored. - Export and verify the ranges around the break with the issuer’s pinned keys:
deedglass deeds export --from-seq <break-100> --to-seq <break+100>, thendeedglass verify <file> --keys <issuer>/.well-known/deedglass/keys.json --all. - Compare with an old export that covers the same seqs. A deed that verified then and fails now was changed after that export; the old copy is what it said.
- Write down the head you have now, and compare it with every head you kept elsewhere.
- Find out who had superuser or file access, from database and host logs. Deedglass can’t tell you; the chain only shows that, and where, something changed.
10. What the Ledger does not do
Section titled “10. What the Ledger does not do”- It never deletes or archives deeds, never repairs a chain and never re-seals anything.
- It doesn’t search inside payloads (
qmatches the tool name and the error code only). - It has no cross-org queries; every read is one org’s.
- The chain check runs inside the database it checks: it can’t see deeds deleted from the end of the chain unless you compare the head with one you kept elsewhere.
- Retention is an operator command today (
deedglass ledger prune); a daily sweep insideserveis planned. - Until S08, the HTTP routes accept only a Console session; agents read them with API keys from S08.
Performance
Section titled “Performance”The S06 §3.10 budgets (100,000 actions in one org, local PostgreSQL 18) are a test,
TestLedgerBudget (make bench-ledger), with raw output in bench/results/. They are development
measurements, not public claims; BENCHMARKS.md explains when numbers become
quotable (S35).