Adeia

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.

The order rules are evaluated in, and what each one returns.
# 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.

  1. 01

    Minted

    32 bytes of CSPRNG output, base64url encoded. Returned exactly once, to the sender, and never logged — not even when sending fails.

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

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

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

THE FULL EVENT VOCABULARY

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.

SDK REFERENCE

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.