Capability
What each part refuses to do
Four pieces, and the interesting thing about each one is not the feature — it is the constraint. The policy engine cannot read a clock. The approval token cannot be replayed. The audit write cannot throw. The SDK will not retry on your behalf. Each refusal is a decision someone had to defend, and each one is why the layer above it can be trusted.
01 — Policy engine
A pure function that cannot cheat
evaluate() takes an action request, a policy, and the amount already spent today, and returns one of three words with the figure that produced it. It reads no clock, no database and no network. Today's spend is passed in rather than looked up, which is what lets every rule be tested on its own with no fixtures and no fake timers.
The order is the enforcement
Rules run top to bottom and the first match wins. Every deny rule runs before any approval rule, and that ordering is load-bearing rather than incidental: if a $2,000,000 payment to an unknown recipient came back as require_approval, a tired human could click a button and pass a hard ceiling that exists precisely so no human has to be trusted at 2am.
| # | Condition | Returns |
|---|---|---|
| 1 | No policy configured for this action type | deny |
| 2 | Policy is for a different action type | deny |
| 3 | Amount is over the hard maximum | deny |
| 4 | Spent today plus this amount is over the daily cap | deny |
| 5 | Policy marks this action type as always requiring approval | require_approval |
| 6 | Amount is over the per-action limit | require_approval |
| 7 | Recipient is not on the allowlist | require_approval |
| 8 | Nothing above matched | allow |
Limits are inclusive
An amount exactly equal to the per-action limit is allowed. Every comparison in the function is >, never >=. A $50.00 payment against a $50.00 limit executes; a test pins this, because it is the sort of thing a refactor quietly flips.
Null and zero are different
null means no limit. 0 means nothing is allowed. Every check compares !== null explicitly, because a truthiness test would read a 0 limit as absent — turning the strictest possible policy into no policy at all.
The cap counts the pending amount
The daily cap is checked against spend plus this request, not spend alone. Otherwise a single action that started the day under the cap could clear it in one move, which is the exact scenario a daily cap exists to stop.
02 — Approvals
A bearer credential, treated like one
The link in the approval email is the only authentication on the page it opens. Whoever holds it can release a payment, so the token is handled the way that sentence implies rather than the way a convenience link usually is.
-
01
Minted
32 bytes of CSPRNG output, base64url encoded. Returned exactly once, to the sender, and never logged — not even when sending fails.
-
02
Stored as a hash
Only sha256(token) reaches the database. A leaked approvals table is a list of hashes, not a set of live approve buttons.
-
03
Single use, checked late
Consumption is enforced when the decision is made, not when the page renders. Both a double-clicked button and a browser re-submitting a POST reach the server twice; only the first one decides anything.
-
04
Expiring
Twenty-four hours by default. An ignored request ends at approval.expired rather than staying live indefinitely — which also stops an SDK from polling a status that will never change.
Approving is never a GET
The decision is a form POST. A GET would mean any preview fetcher, link scanner, corporate mail gateway or over-eager browser prefetch could approve a payment by looking at the email. Several of those exist and none of them ask first.
03 — Audit
Append-only, and it never takes the action down with it
Every state change writes one row. The event name is not a free-text string but a TypeScript union, so a typo like action.exectued is a compile error rather than a trail that quietly loses a step.
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.
Secrets stripped at write time
One expression tests key names — not values — and replaces matches with [redacted], leaving the key in place so the trail still shows a field was there. Redacting on read would leave the secret sitting in the database file, and the file is the thing that leaves the building.
It is a denylist, and denylists leak
It catches key names it knows. It will not catch a live credential pasted into a free-text description, because nothing about that value says otherwise. Redaction is the backstop, not the plan.
Stable order, bounded size
Events sort by timestamp then insertion order, because SQLite routinely writes several inside the same millisecond and sorting on the clock alone reshuffles the trail on every read. The data column caps at four kilobytes so a chatty adapter cannot bloat the table.
04 — SDK
Three methods, and no retry
requestAction resolves with a decided record whatever the outcome — executed, held, or refused. Check status. getAction fetches one. waitForAction polls until the action reaches a terminal state. That is the whole surface.
It will not retry for you
Deliberately. A blind retry against a payment endpoint is how double charges happen. The idempotency key — generated for you if you do not pass one — makes a retry safe; deciding to make one stays yours.
Waiting has a deadline
Five minutes by default, polled every two seconds. Long enough for a human to read an email and click through, short enough that an ignored request does not hang an agent forever.
A denial is not an exception
The request was well formed and the call succeeded; the answer was no. It comes back as a status with the figure that produced it, so an agent can say the reason out loud to whoever asked and stop — instead of retrying a call that will never pass.
Not on npm
@adeia/sdk ships inside the repository as a workspace. There is no published package yet, so npm install @adeia/sdk will not work. Copy the demo agent to start.
05 — Adapters
The seam, deliberately empty
An adapter is what actually does the thing once policy and a human have both said yes. The registry takes any number of them; one is attached.
It is the ledger adapter, and it records the payment and stops where settlement would begin — status: 'recorded', settled: false, no processor identifier. It does not imitate a payment processor, because a convincing fake is the one thing worse than an obvious gap: you cannot tell, later, which of your test runs moved money.
The server says NO PAYMENT PROCESSOR ATTACHED on every boot. A permission layer that has quietly stopped executing anything looks identical, from the outside, to one that is working — the audit log fills up either way. The one thing that must never happen silently is nobody knowing which of the two this is.
payment is the only action type that exists. The request schema is a discriminated union with exactly one member, so anything else is a validation error today — not a missing adapter, a missing type.
Two of the three layers are ready for a second one. The adapter registry takes any number of them, and the policy is stored against an action type and refuses to answer for a different one. The layer that is not ready is the rule set: every rule the engine has is a money rule — per-action limit, daily cap, hard ceiling, recipient allowlist — and its parameters are typed as payment parameters.
So "delete these records" or "email this list" would need more than an adapter. It would need a policy shape that fences something other than an amount: a row count, a recipient count, a blast radius. That is a real piece of design work rather than a configuration change, and it has not been done.