authoxi docs

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:

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.

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.

vectors/approved-payment.json

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).