loss-event/v2 — a verifiable record of an agent authorization decision
Status: open spec · Version: 2 · Date: 2026-07-14 · License: CC BY 4.0
Reference verifiers: npx -p @authoxi/js authoxi-verify ·
pip install authoxi → from authoxi import verify_event · and a WebCrypto module that runs in a
browser. Three implementations, sharing no code, pinned to one known-answer vector (§9).
An AI agent asked to do something irreversible. Something decided whether it could. A human may have signed off. This is the record of that decision, in a form a stranger can verify offline — with no registry, no network call, and no trust in the party that produced it.
Anyone may implement this. It is not owned by a vendor, and the reference verifier does not require one. If your implementation disagrees with the reference by one byte, one of us has a bug; the test vector in §9 will say which.
1. What this is, and what it isn't
A loss-event is one consequential agent action whose authorization was decided and recorded.
It is not a log line — a log is written by, and mutable by, the party you may later need to hold accountable. It is not a trace or a span; those record what a system did. This records what a system was permitted to do, and who said so.
The distinction is the whole reason the format exists. When an auditor, a regulator, a customer's security team or an incident review asks "prove that this specific human approved this specific $9,000 payment, and that nobody edited the record afterwards" — a database row cannot answer, because everyone with write access to that database is a candidate suspect. A signature can.
Design constraint, stated once: every field below exists so that a third party who trusts nobody can reach a verdict using nothing but the event itself.
2. The two signatures
| signer | when | attests |
|---|---|---|
| emitter — the gate that enforced the decision | always | "I made this decision and recorded it faithfully." |
| approver — the human, via their own key | when decision_by = "human" |
"I decided this, and I had the authority to." |
An event signed only by the emitter is witnessed. An event signed by both is evidence.
The emitter is never the agent. An agent signing its own authorization record grades its own homework — and its key is dead exactly when you need it most (a revoked agent cannot sign the event recording why it was revoked).
Order matters, and it is not symmetric. The approver signs first, at decision time, over a body that excludes the entire emitter envelope — they cannot attest to a countersignature that doesn't exist yet. The emitter then signs last, over a body that includes the approver's whole envelope, signature and all.
This asymmetry is load-bearing. Sign the two independently and an attacker can lift a valid approval
off a $9,000 payment and staple it onto a $9,000,000 one, with both signatures still verifying.
Because the emitter countersigns the approver's envelope, an approval is bound to this event and no
other. (Reference verifier, test.js: "lifting a valid approval onto a DIFFERENT action is
caught.")
3. Fields
All 34 keys MUST be present in every event, with null where unset. A key that is merely
absent canonicalizes to different bytes than one explicitly set to null — so two implementations
that disagree about omission produce signatures that never verify. Emit every key, always.
3.1 Identity of the decision
| field | type | req | meaning |
|---|---|---|---|
schema_version |
string | ✓ | Literal "loss-event/v2". Inside the signed body — otherwise an attacker downgrades to a version with no signature and strips it. |
event_id |
string | ✓ | Unique. The idempotency key; a redelivery must not double-count. |
seq |
integer | ✓ | Monotonic per agent, issued by the emitter. See §5 — this is what makes suppression detectable. |
ts |
string | ✓ | RFC 3339 UTC, e.g. 2026-07-14T16:20:05Z. |
provenance |
string | ✓ | production · staging · test. Keeps test events out of a real dataset. |
3.2 Who acted, and on whose behalf
| field | type | req | meaning |
|---|---|---|---|
agent_did |
did:key | ✓ | The acting agent. Self-certifying — carries its own public key. |
principal_did |
did | ✓ | The human or org the agent acted for. The delegation chain's root. |
3.3 What was attempted
| field | type | req | meaning |
|---|---|---|---|
action_type |
string | ✓ | e.g. payment, repo_write, refund, provision. |
action_payload_hash |
string | ✓ | sha256:<hex> of the canonical payload. The raw payload never appears in an event — the record must be safe to hand to a stranger. |
authorized_amount |
string | null | ✗ | Decimal string, e.g. "9000.00". The amount actually permitted. (New in v2 — v1 could prove a payment was permitted, but not for how much.) |
currency |
string | null | ✗ | ISO 4217. |
3.4 The decision
| field | type | req | meaning |
|---|---|---|---|
decision |
enum | ✓ | approve · deny · override · timeout · auto |
decision_by |
enum | ✓ | human · policy · agent |
outcome |
enum | ✓ | success · failure · loss · averted |
loss_class |
enum | ✓ | financial · data · compliance · reputational · none |
severity |
int 0–4 | ✓ | |
verified |
bool | ✓ | Derived, never asserted. True iff a valid approver signature is present. A verifier MUST check the signature and MUST NOT trust this flag. |
reason_class |
string | null | ✗ | policy · process · business — why, structurally. |
reason_code |
string | null | ✗ | e.g. budget_exceeded, wrong_recipient. A free-text "denied" is worthless; a structured reason code is a label. |
reason_note |
string | null | ✗ | The human's own words. Covered by their signature — so it cannot be rewritten afterwards. |
3.5 Joins
| field | type | req | meaning |
|---|---|---|---|
authz_ref |
string | null | ✗ | The authorization envelope (mandate) that bounded this action. |
pending_id |
string | null | ✗ | Required when decision_by = "human". The escalation object the gate returned when it blocked — the join back to the call that was stopped. |
trace_ref |
string | null | ✗ | The execution trace, if an observability system recorded one. |
3.6 Outcome accounting
| field | type | req | meaning |
|---|---|---|---|
loss_amount |
string | null | ✗ | What it actually cost, if it went wrong. |
counterfactual_loss_amount |
string | null | ✗ | What the block PREVENTED. Without this, every successful prevention is a $0 row and the value of the control can never be measured. On a deny, this is the amount that didn't leave. |
reconciled |
bool | null | ✗ | Whether the outcome was later confirmed against a system of record. |
3.7 The signature envelope
| field | type | req | meaning |
|---|---|---|---|
sig_alg |
string | ✓ | ed25519 (the only value in v2). |
emitter_did |
did:key | ✓ | Self-certifying. Carries the public key — this is what removes the registry. |
emitter_kid |
string | ✓ | Key id, for rotation. |
emitter_sig |
string | ✓ | ed25519:<128 hex chars> |
approver_did |
did:key | null | ✗ | Required when decision_by = "human". |
approver_kid |
string | null | ✗ | sha256(did)[:16] — deterministic, so the human's client and the gate reach the same value without coordinating. |
approver_sig |
string | null | ✗ | ed25519:<128 hex chars> |
approver_mandate_ref |
string | null | ✗ | The grant that gave this human authority to approve this class of thing, up to this amount. Authority is itself auditable. |
4. Canonicalization and signing — the wire contract
Two implementations that disagree by one byte produce signatures that never verify. This section is normative. Everything else is commentary.
Canonical form. JSON with:
- object keys sorted by Unicode code point (ascending),
- no insignificant whitespace — separators are exactly
,and:, - non-ASCII characters emitted raw as UTF-8 (not
\u-escaped), - floats REJECTED. A float does not serialize portably across languages, so it cannot be signed reproducibly. Amounts are decimal strings. An implementation that encounters a float MUST error, not coerce.
Signing input = sha256(canonical(body)) — the raw 32-byte digest, not the canonical bytes
themselves, and not a hex string of the digest.
Signature encoding = the literal prefix ed25519: followed by the 64-byte signature as lower-case
hex (128 characters).
What each party signs — the body is the whole event, minus these keys:
| signer | excluded from the signed body |
|---|---|
| approver | emitter_did, emitter_kid, emitter_sig, approver_sig — and verified is normalized to true before canonicalizing, because signing is what earns it. The flag must be inside the bytes the human attested to. |
| emitter | emitter_sig only. Everything else — including approver_sig — is covered. |
did:key derivation. did:key:z + base58btc( 0xed 0x01 ‖ raw-32-byte-Ed25519-public-key ).
Verification recovers the key from the DID and checks the signature. No registry lookup. No network
call. That property is not a convenience; it is what makes the record evidence rather than a claim.
5. seq, and why a signature is not enough
A signature proves an event was not altered. It proves nothing about whether the record is whole — and the easiest attack on an audit trail was never forgery, it was deletion.
seq is a monotonic per-agent counter issued by the emitter and inside the signed body. Suppress
event 42 and you leave a hole at 42 that you cannot renumber around without forging the emitter's
signature over every subsequent event.
A verifier processing a stream MUST check seq continuity per agent_did and report gaps. A gap
is a decision that somebody did not want you to see. The reference verifier does this and treats a
hole as a failure.
The counter is issued by the gate, not by the witness that stores the events. A witness must never number itself — a sequence the record-keeper can rewrite proves nothing.
6. Verifying (normative algorithm)
verify(event):
1. REQUIRE event.schema_version == "loss-event/v2" # else: refuse. no downgrade.
2. REQUIRE event.emitter_sig and event.emitter_did
body := event minus {emitter_sig}
key := public_key_from(event.emitter_did)
REQUIRE ed25519_verify(key, sha256(canonical(body)), event.emitter_sig)
3. IF event.approver_sig:
REQUIRE event.approver_did
view := event with {verified: true, approver_sig: null}
body := view minus {emitter_did, emitter_kid, emitter_sig, approver_sig}
key := public_key_from(event.approver_did)
REQUIRE ed25519_verify(key, sha256(canonical(body)), event.approver_sig)
4. IF event.decision_by == "human" and not event.approver_sig:
FAIL "unattributable human decision"
5. IF event.verified == true and step 3 did not run and pass:
FAIL "forgery: verified claimed with no valid approver signature"
Step 5 is not optional. verified is derived, never asserted. A verifier that trusts the flag
instead of the signature verifies nothing at all.
7. What this format deliberately does NOT prove
An honest spec states its limits, because an implementer who doesn't know them will overclaim to someone who is relying on the answer.
- It does not bind a key to a person. A signature binds an action to a key. Binding that key to a named human is an identity process outside this spec. What the format guarantees is that the holder of that key, whoever they are, cannot later deny the decision.
- It does not prove the action executed. This is a record of an authorization, not of an effect.
- It does not prove delivery.
seqmakes suppression detectable by a reader who receives the stream. It cannot help a reader who never receives anything. - It does not make an unsigned system trustworthy. If the emitter signs whatever the agent tells it, the signature is valid and the content is garbage. Signatures move trust; they do not create it.
8. Why not just use…
…an append-only database / immutable log? Because its immutability is a property of your infrastructure, and the party asking for proof is often asking precisely because they do not trust your infrastructure. A signature is a property of the record, and it survives leaving your building.
…a hash chain? A hash chain proves an internally consistent ordering. It does not prove who
decided, and rebuilding a chain is trivial for whoever holds the log. Signatures and chains solve
different problems — chain these events by all means; the chain is not a substitute for approver_sig.
…OpenTelemetry spans? A span records what happened. It has no notion of authority, no human subject, and no signature. An observability system is a witness, not a judge.
…a vendor's audit UI? If a vendor must be alive, reachable and cooperative for you to validate
your own audit trail, it is not an audit trail. npx -p @authoxi/js authoxi-verify works if we are offline, acquired,
or dead. That is a deliberate property, and you should demand it of anyone selling you this.
9. Test vector
A real, doubly-signed event produced by the reference signer. Any conforming verifier MUST accept it, and MUST reject every mutation of it.
npx -p @authoxi/js authoxi-verify vectors/approved-payment.json # PASS
12 attack vectors are run against it — repriced amount, flipped decision, rewritten human rationale,
stripped signature, verified: true with nothing behind it, an approval transplanted onto a bigger
payment, a substituted approver key, a schema downgrade, a truncated signature, a float amount.
All twelve must fail, in every implementation. If any passes, that implementation is lying.
This vector is the contract, and it is regenerated from the signer. Three implementations
(Python, node:crypto, WebCrypto) that share no code all verify it, and the build fails if the
signer's output stops matching. That is why this document can claim the byte-level details in §4 are
true rather than merely intended — a spec nobody has independently implemented is a wish.
10. Changes from v1
| # | Change | Why |
|---|---|---|
| 1 | seq added, inside the signed body |
A signature proves an event is unaltered. Only a signed sequence proves the record is whole. Suppression was the unaddressed attack. |
| 2 | authorized_amount added |
v1 could prove a payment was permitted, but not for how much — a gap the reference implementation's own audit documented against itself. |
| 3 | schema_version is loss-event/v2 and is signed |
Refusing a downgrade is a verifier obligation, not a suggestion. |
Note on encoding, for anyone implementing against an older draft: signatures are
ed25519:<hex>, not bare base64url, and the signing input is the raw SHA-256 digest of the canonical bytes, not the canonical bytes. Earlier internal drafts said otherwise. This document and the reference verifier are the wire truth; they are validated against the reference implementation's own signer by a cross-implementation test vector (§9).