Skip to content
adeia fence
Sign in
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.

12 events in the vocabulary
0 code paths that update or delete
4 KB cap on the data column
write when redaction happens
Vocabulary

Twelve 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 service.ts · requestAction() { type, params } — the request exactly as it validated
policy.evaluated service.ts · requestAction() { decision, reason, spentTodayCents, policyId } — the verdict and the running total it was measured against
action.classified service.ts · resolveOutcome() { risk, reason, model } — a model, not a person, judged this one. Its own event rather than a field on policy.evaluated, so reading the trail never confuses the two
action.denied terminal service.ts · requestAction() { reason } — the rule that refused it, with the number
action.pending_approval 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 service.ts · approveAction() { decidedBy } — who said yes
approval.denied terminal service.ts · denyAction() { decidedBy } — who said no
approval.expired terminal service.ts · expireAction() { expiredAt } — nobody decided in time, and the action stopped waiting
action.executing service.ts · execute() { adapter } — which adapter was handed the action
action.executed terminal service.ts · execute() { result } — whatever the adapter returned, written through unchanged
action.failed terminal service.ts · execute() { error } — the adapter's own error code where it has one, its message otherwise
Eleven of the twelve 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

01

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.

02

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.

03

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.

04

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

$25.00, straight through

npm run audit -- act_j4m0q7wc2rt8zb5nka13v
act_j4m0q7wc2rt8zb5nka13v  payment  executed
  params  { amountCents: 2500, currency: 'usd', recipient: 'acct_cloudhost' }
  policy  within policy
  result  { status: 'recorded', settled: false, amountCents: 2500 }

14:02:09  action.requested    { type: 'payment', params: { … } }
14:02:09  policy.evaluated    { decision: 'allow', reason: 'within policy', spentTodayCents: 0 }
14:02:09  action.executing    { adapter: 'ledger' }
14:02:09  action.executed     { result: { status: 'recorded', settled: false } }

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

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.