Adeia

Audit log

Who let that through

Months after an agent spent your money, that question has one answer and it should not be a reconstruction. Adeia writes an event for every step an action takes — what was asked for, which rule answered, the figure that produced the answer, the person who said yes, and what the adapter did next — in order, at the time, and never edited since.

Vocabulary

Eleven events. Nothing outside the list.

The event name is not a free-text string. It is a TypeScript union exported from audit/log.ts, so action.exectued is a compile error rather than a trail that quietly loses a step. A test also reads back every distinct event name in the database and fails if one of them is not on this list.

Every audit event, the code that writes it, and the fields recorded in its data column.
Event Written by Data
action.requested actions/service.ts · requestAction() { type, params } — the request exactly as it validated
policy.evaluated actions/service.ts · requestAction() { decision, reason, spentTodayCents, policyId } — the verdict and the running total it was measured against
action.denied terminal actions/service.ts · requestAction() { reason } — the rule that refused it, with the number
action.pending_approval actions/service.ts · requestAction() { reason } — why a human is being asked
approval.sent server.ts · createApprovalNotifier() { to, expiresAt } — the address the request went to, and when the link stops working. The token itself is never written here
approval.granted actions/service.ts · approveAction() { decidedBy } — who said yes
approval.denied terminal actions/service.ts · denyAction() { decidedBy } — who said no
approval.expired terminal actions/service.ts · expireAction() { expiredAt } — nobody decided in time, and the action stopped waiting
action.executing actions/service.ts · execute() { adapter } — which adapter was handed the action
action.executed terminal actions/service.ts · execute() { result } — whatever the adapter returned, written through unchanged
action.failed terminal actions/service.ts · execute() { error } — the adapter's own error code where it has one, its message otherwise

Ten of the eleven come out of one file, and that is deliberate: actions/service.ts is the only module allowed to change an action's status, so the event is written next to the change it describes rather than somewhere that has to be kept in step with it. approval.sent is the exception, because it is a claim about the outside world — it is written by the notifier, after the mail provider has accepted the message, not when the send was attempted.

The guarantee

Append-only, and complete

  1. No update, no delete

    One function writes the table and all it does is insert. There is no code path that edits an event or removes one, and none is coming — a log you can quietly correct answers a different question from the one people ask it. When a record turns out to be wrong, the fix is another event, so the mistake and the correction both stay in the trail where anyone reading it can see what happened.

  2. Every transition writes a row

    A status change with no event is a bug, not a gap we live with. The suite drives an action down each terminal path and asserts the exact sequence, then walks every action in the database and fails if one is sitting in a terminal status with no terminal event beside it. That second check is the one that catches a transition somebody adds later and forgets to record.

  3. The record never takes the action down with it

    The write cannot throw. If the insert fails it says so loudly on stderr, names the event and the action, and returns. Losing a record is bad; unwinding an action that already completed because the logging failed is worse, and reversing a real transaction over a logging error is worse still.

  4. Stable order, bounded size

    Events come back sorted by timestamp and then by insertion order, because SQLite routinely writes several of them inside the same millisecond and sorting on the clock alone reshuffles the trail on every read. The data column is capped at four kilobytes; past that the row keeps a marker, the real byte count and the first 512 characters, so a chatty adapter cannot bloat the table.

What that buys you is a straight answer. Not "it looks like it was approved" — a name, a timestamp, and the figure that produced the decision. And when the answer is that nobody let it through, that the policy refused it outright and no approval request was ever sent, the same trail says so with the number that refused it.

One action, end to end

This is the artefact

Everything else Adeia does exists to produce this block. It is what npm run audit -- <actionId> prints: the action row first, then every event that was written against it, in the order they were written. The time column is the UTC time of day cut straight out of the stored ISO-8601 timestamp — the CLI does not shift it into a local zone, so two people in two countries reading the same trail read the same numbers.

Inside the fence · four events · nobody was asked

act_j4m0q7wc2rt8zb5nka13v  payment  executed
  params  { amountCents: 2500, currency: 'usd', recipient: 'acct_cloudhost', description: 'monthly hosting' }
  policy  within policy
  result  { ledgerEntryId: 'led_act_j4m0q7wc2rt8zb5nka13v', status: 'recorded', settled: false, amountCents: 2500, currency: 'usd', recipient: 'acct_cloudhost' }

14:02:09  action.requested         { type: 'payment', params: { amountCents: 2500, currency: 'usd', recipient: 'acct_cloudhost', description: 'monthly hosting' } }
14:02:09  policy.evaluated         { decision: 'allow', reason: 'within policy', spentTodayCents: 0, policyId: 'pol_5r8t1yc6ks0m3wq9dz47b' }
14:02:09  action.executing         { adapter: 'ledger' }
14:02:09  action.executed          { result: { ledgerEntryId: 'led_act_j4m0q7wc2rt8zb5nka13v', status: 'recorded', settled: false, amountCents: 2500, currency: 'usd', recipient: 'acct_cloudhost' } }

$25.00, under the $50.00 per-action limit, so the policy answered allow and the action went straight to its adapter. No email, no waiting, and the whole trail is four rows long. Note the result: status: 'recorded' and settled: false. The action completed; no money moved.

Over the limit · seven events · held for 95 seconds

act_7h2vq9dk4m1x6ts0bz8ry  payment  executed
  params  { amountCents: 50000, currency: 'usd', recipient: 'acct_contractor', description: 'Q3 design work' }
  policy  amount 50000 exceeds per-action limit 5000
  result  { ledgerEntryId: 'led_act_7h2vq9dk4m1x6ts0bz8ry', status: 'recorded', settled: false, amountCents: 50000, currency: 'usd', recipient: 'acct_contractor' }

14:02:11  action.requested         { type: 'payment', params: { amountCents: 50000, currency: 'usd', recipient: 'acct_contractor', description: 'Q3 design work' } }
14:02:11  policy.evaluated         { decision: 'require_approval', reason: 'amount 50000 exceeds per-action limit 5000', spentTodayCents: 2500, policyId: 'pol_5r8t1yc6ks0m3wq9dz47b' }
14:02:11  action.pending_approval  { reason: 'amount 50000 exceeds per-action limit 5000' }
14:02:12  approval.sent            { to: 'ops@example.com', expiresAt: '2026-08-11T14:02:12.086Z' }
14:03:47  approval.granted         { decidedBy: 'ops@example.com' }
14:03:47  action.executing         { adapter: 'ledger' }
14:03:47  action.executed          { result: { ledgerEntryId: 'led_act_7h2vq9dk4m1x6ts0bz8ry', status: 'recorded', settled: false, amountCents: 50000, currency: 'usd', recipient: 'acct_contractor' } }

$500.00 against a $50.00 per-action limit. The policy said hold, the action stopped where it stood, and the notifier mailed a named address one second later. Nothing touched the adapter for the next ninety-five seconds. Read the gap between approval.sent at 14:02:12 and approval.granted at 14:03:47: that is a person reading an email and deciding, and the fact that action.executing comes after it and not before is the entire claim this product makes.

Over the limit · five events · the adapter was never reached

act_8k3rq0vm6c2t9wz5nb1xd  payment  denied
  params  { amountCents: 90000, currency: 'usd', recipient: 'acct_newvendor', description: 'design tooling licence' }
  policy  denied by ops@example.com

14:11:30  action.requested         { type: 'payment', params: { amountCents: 90000, currency: 'usd', recipient: 'acct_newvendor', description: 'design tooling licence' } }
14:11:30  policy.evaluated         { decision: 'require_approval', reason: 'amount 90000 exceeds per-action limit 5000', spentTodayCents: 52500, policyId: 'pol_5r8t1yc6ks0m3wq9dz47b' }
14:11:30  action.pending_approval  { reason: 'amount 90000 exceeds per-action limit 5000' }
14:11:31  approval.sent            { to: 'ops@example.com', expiresAt: '2026-08-11T14:11:31.400Z' }
14:14:06  approval.denied          { decidedBy: 'ops@example.com' }

$900.00 to a recipient nobody recognised. It cleared the $1,000.00 ceiling and the daily cap, so the policy asked rather than refused — and the human said no. There is no action.executing row, because there was no execution: the trail simply ends at the decision. The action row's policy line is rewritten to denied by ops@example.com, so the summary and the events agree about who stopped it.

Past the ceiling · three events · no email was sent

act_2c9wq5nk7b0m4rt1xz8dh  payment  denied
  params  { amountCents: 500000, currency: 'usd', recipient: 'acct_contractor', description: 'annual retainer' }
  policy  amount 500000 exceeds hard maximum 100000

14:05:02  action.requested         { type: 'payment', params: { amountCents: 500000, currency: 'usd', recipient: 'acct_contractor', description: 'annual retainer' } }
14:05:02  policy.evaluated         { decision: 'deny', reason: 'amount 500000 exceeds hard maximum 100000', spentTodayCents: 52500, policyId: 'pol_5r8t1yc6ks0m3wq9dz47b' }
14:05:02  action.denied            { reason: 'amount 500000 exceeds hard maximum 100000' }

$5,000.00 against a $1,000.00 hard ceiling. Three events, all inside the same millisecond, and no approval.sent anywhere in them — nobody was asked, because there is no button that lets a tired human wave this one through. The reason carries the figure that produced it, which is what lets the agent read the refusal, say it out loud to whoever asked, and stop rather than retry a call that will never pass.

Two more terminal paths exist and print the same way. An adapter that throws ends at action.failed with the error code it raised, and an approval nobody answers ends at approval.expired once the token's twenty-four hours are up — which is what stops an ignored email leaving an action pending forever and the SDK polling a status that will never change.

Redaction

Stripped as the row is written

One expression decides: /secret|key|token|password|authorization/i, tested against the key names in the data payload, not the values. A match replaces the value with [redacted] and leaves the key in place, so the trail still shows that a field was there. It recurses through nested objects and arrays, and stops at a depth limit rather than following a cycle forever.

It runs at write time. Redacting on read would be the easier change and the wrong one: the secret would still be sitting in the database file, and the file is the thing that leaves the building — copied to a laptop, attached to a bug report, handed to whoever is debugging this on Friday. An API response that hides a value the disk still holds is a courtesy, not a control.

It over-catches on purpose. idempotencyKey contains "key" and gets redacted along with everything else, and that is fine — losing a harmless field from a trail is a cosmetic problem, and the mistake in the other direction is not.

Now the part that matters: this is a denylist, and denylists leak. It catches key names it knows. It will not catch a live credential that an agent pasted into a free-text description, because the key is called "description" and nothing about the value says otherwise. The real defence is not putting secrets in there in the first place. Redaction is the backstop, not the plan, and treating it as the plan is how a key ends up in an append-only table that by design cannot be edited to remove it.

What the caller passes
appendAudit(db, {
  projectId,
  event: "policy.evaluated",
  data: {
    apiKey: "adeia_sk_live_9f3k…",
    amountCents: 100,
  },
});
What the row holds
{"apiKey":"[redacted]","amountCents":100}

A test asserts exactly this: that the string amountCents is present in the stored bytes and the secret is not.

Where this sits

The record is the product

A policy engine you cannot audit is a promise. The trail is what turns it into something a finance team, a security review or a colleague eight months from now can check for themselves — without asking anyone to remember what happened.

SEE THE LEDGER