Skip to content

About

⚡ A spending-policy layer for wallets on Tether's WDK: per-agent envelopes graded before anything signs — asset, destination, per-transaction and daily caps. Three verdicts, always with a reason: ALLOW, ESCALATE, DENY. Apache-2.0 — npm install @flashy/wdk-policy-guard. The policy is open; the money it guards is not.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

6 stars

Watchers

0 watching

Forks

Latest commit

 

History

22 Commits

Folders and files

@flashylabs/wdk-policy-guard

        ██
       ██
      ██████
        ██
       ██
      ██

A spending-policy layer for wallets built on Tether's WDK — or any wallet SDK. It grades a proposed spend against a per-agent envelope before anything signs, and returns one of three verdicts: ALLOW, ESCALATE, or DENY, always with a reason.

tests CodeQL Scorecard npm version npm downloads license node

Built by Flashy Labs — part of the open-source toolkit we ship for teams building on Tether's WDK. Its sibling package is @flashylabs/wdk-staking-kit.

Why this exists

WDK ships a policy engine with denial codes, but nothing that grades a transfer against per-agent limits before it reaches a chain — daily caps, an allowlist of destinations, an amount under which nobody has to be asked. Any team giving an AI agent a wallet needs exactly this, and building it from scratch is the same twenty lines of careful BigInt arithmetic every time, usually written once, under deadline, and never given the test coverage the money it protects deserves.

This package is that twenty lines, done once, tested from every angle we could find, and released so nobody else has to write it a second time.

Install

npm install @flashylabs/wdk-policy-guard

Quickstart

import { grade, DailyLedger } from '@flashylabs/wdk-policy-guard'

const envelope = {
  chain: 'evm:84532',
  kinds: ['transfer'],
  assets: ['native', '0xTokenAddress...'],
  destinations: ['0xKnownVendor...'],
  perTxMax: '1000000000000000000',   // 1.0 in 18-decimal base units
  dailyMax: '5000000000000000000',   // 5.0/day
  autoApproveMax: '100000000000000000', // 0.1 auto-approves; above it, a human confirms
}

const ledger = new DailyLedger()

const verdict = grade(envelope, {
  kind: 'transfer',
  chain: 'evm:84532',
  asset: 'native',
  amount: '50000000000000000', // 0.05
  destination: '0xKnownVendor...',
}, ledger)

if (verdict.verdict === 'ALLOW') {
  ledger.reserve('evm:84532', '50000000000000000')
  // now, and only now, call your wallet's send/transfer method
} else if (verdict.verdict === 'ESCALATE') {
  // show a human the spend and its verdict.impact ('MEDIUM' | 'HIGH' | 'CRITICAL')
} else {
  // verdict.code is one of the published DENIAL_CODES; verdict.reason is for a log or a UI
}

Wiring it in front of a real send

import { guardSend, DailyLedger } from '@flashylabs/wdk-policy-guard/adapters/wdk'

const ledger = new DailyLedger()
const guardedTransfer = guardSend(
  (spend) => myWdkWallet.transfer(spend), // whatever your WDK wallet instance's send method is
  { getEnvelope: () => currentEnvelopeFor(agentId), ledger },
)

// Only ever runs myWdkWallet.transfer(...) on ALLOW. Throws PolicyDeniedError
// or PolicyEscalationRequiredError otherwise — before anything is signed.
await guardedTransfer({ kind: 'transfer', chain: 'evm:84532', asset: 'native', amount: '50000000000000000', destination: '0xKnownVendor...' })

See examples/basic.mjs for a runnable end-to-end example, and ARCHITECTURE.md for the full design rationale.

API

Export From What it does
grade(envelope, record, ledger?) . The core function. Returns {verdict:'ALLOW'}, {verdict:'ESCALATE', impact}, or {verdict:'DENY', code, reason}.
validateEnvelope(envelope) . Returns an array of problem strings; empty means valid. Catches malformed caps before grade() ever sees them.
validateRecord(input) . The record-shape check grade() runs first. Exported so you can validate a spend independently.
DailyLedger . An in-memory per-chain, per-UTC-day reservation tracker. reserve(), release(), used(). Swap in your own persistent implementation with the same three methods for a multi-process deployment.
DENIAL_CODES, KINDS, VERDICTS . The closed vocabularies every DENY, spend kind, and verdict is drawn from.
guardSend(send, opts) ./adapters/wdk Wraps any async send function with a grade() check — call-through only on ALLOW.
PolicyDeniedError, PolicyEscalationRequiredError ./adapters/wdk Thrown by guardSend()'s wrapped function on DENY / ESCALATE.

Full TypeScript declarations ship with the package — Envelope and SpendRecord import directly from the package root (import type { Envelope } from '@flashylabs/wdk-policy-guard'), generated from the source's own JSDoc so the types can never drift from the implementation. test-types/consumer.ts is the type-level test that would fail if they ever did.

What it refuses

Every one of these is a real test in test/policy.test.mjs, not a claim:

Code When
INVALID_RECORD The spend is missing a required field, or shaped wrong. Never guessed at.
INVALID_AMOUNT The amount isn't a non-negative integer string in base units.
NO_ENVELOPE No envelope exists for this chain, or the envelope is for a different chain.
ENVELOPE_INACTIVE The envelope has been explicitly revoked (active: false).
KIND_NOT_PERMITTED The spend's kind (transfer / swap / bridge) isn't in the envelope's allowlist.
ASSET_NOT_PERMITTED The asset isn't in the envelope's allowlist (matched case-insensitively).
DESTINATION_NOT_PERMITTED The destination isn't in the envelope's allowlist. Never checked for a swap, which has no destination.
PER_TX_CAP The amount exceeds the envelope's per-transaction cap.
DAILY_CAP Today's reservations plus this spend would exceed the daily cap.

validateEnvelope() also refuses an envelope whose autoApproveMax exceeds its perTxMax, or whose perTxMax exceeds its dailyMax — a self-contradictory envelope is caught before it can silently allow more than it claims to.

Invariants

INVARIANTS.md states the three guarantees this layer makes about a spend it stands in front of — never over-reserve a day, never negative, deny and escalate never send — each with the code that enforces it and the tests that prove it. The document is checked, not decorative: node vendor-invariants.mjs check . (the estate's invariants/1 harness, vendored byte-for-byte from spec-kit) fails when a citation names a test that does not exist, and test/invariants.test.mjs drives the real guardSend and DailyLedger through every pair of operations under every schedule — two of them across UTC midnight — through random command sequences with a send still in flight when the day changes, and through one deliberately broken variant per invariant that the harness must refuse. The midnight defect fixed at 0.2.0 held for one operation at a time and broke for a pair; this is the test that runs two things at once.

Check your own implementation against ours

The rules above are published as a conformance corpus: 44 cases, 31 of them refusals, as data rather than as our test suite. Point it at any executable, in any language:

# from a clone
npx @flashyos/conformance-kit \
  conformance/policy-guard-1.json -- ./my-policy-engine

# or from an install, where the corpus ships inside the package
npx @flashyos/conformance-kit \
  node_modules/@flashylabs/wdk-policy-guard/conformance/policy-guard-1.json \
  -- ./my-policy-engine

Both work offline. The corpus is a file in the package, not a URL of ours — a checker that depends on the party being checked is one you would be right to refuse.

Your program reads one JSON object per line and writes one per line. That is the whole interface — no bindings, no SDK, no import of ours:

in   { "id": "per-tx-cap-exact", "set": "caps", "input": {…}, "context": { "envelope": {…} } }
out  { "id": "per-tx-cap-exact", "verdict": "ESCALATE" }

examples/adapter.mjs is this package doing exactly that, in about twenty lines.

ALLOW and ESCALATE are different answers. ESCALATE means a human is asked before anything is signed, and an implementation that folds it into "allowed" signs what a person was meant to see. That is why the corpus declares a three-outcome vocabulary rather than a boolean — the case a pass/fail corpus cannot express is the one worth checking.

The edges are the point. Any two policy engines agree that a small transfer inside an envelope is allowed; they disagree about an amount exactly equal to a cap, an asset address in the wrong case, a swap with no destination, and an amount larger than a double can represent. The corpus is those edges, and one case exceeds a cap by exactly one at a magnitude where floating point cannot tell the two apart.

The most useful thing you can send us is a disagreement. Two implementations that have never met, agreeing about what to refuse, is the only real evidence a specification says what it means — and where they disagree, one of us is wrong and it is not always you.

The corpus is generated from conformance/cases.mjs by npm run conformance, and npm run conformance:check fails if the committed copy has drifted.

Design principles

  • Amounts are always strings, in base units. Never a JS number. A comparison on floats is how a cap stops being a cap — see ARCHITECTURE.md for the specific failure this avoids.
  • grade() is pure. It reads the ledger's used() but never writes it. Reserving happens after ALLOW, and is the caller's responsibility — this keeps grading safe to call speculatively (to preview a verdict in a UI) without side effects.
  • A refusal always carries a reason. DENY codes are drawn from a closed, published list (DENIAL_CODES) — never an ad-hoc string — so a refusal read by a person and a refusal read by a log line always mean the same thing.
  • No dependency on any wallet SDK. The core (grade, validateEnvelope, DailyLedger) imports nothing beyond its own codes.js. WDK integration lives entirely in the optional ./adapters/wdk entry point, which itself has zero @tetherto/* imports — see ARCHITECTURE.md.
  • Nothing here is clever. Every rule is one comparison a reviewer can check in thirty seconds — the security property is the absence of anything to be clever about.

What this package does not do

  • It does not sign anything, hold a key, or call a network. It is a pure function over data you provide.
  • It does not decide who may set or revoke an envelope — that's an authorization question for your own application, upstream of this package.
  • It does not persist anything on its own. DailyLedger's default is in-memory and single-process: the daily-cap race it closes is closed only within one process, so two workers, pods or devices each hold an independent ledger and the daily cap can be exceeded between them. For a multi-process deployment, back the same three methods (used / reserve / release) with a shared, atomically-incremented store (Redis, a DB row). A persistent DailyLedger that closes the race across processes is the roadmap Next — the interface is stable, the implementation is your integration's until then. See ARCHITECTURE.md.

Status

Pre-1.0 (0.2.0). The Envelope and SpendRecord shapes are not yet frozen — a field added later ships as a minor version, but a field renamed or a verdict's meaning changed would not. Watch CHANGELOG.md across a version bump before pinning a wider range than ^0.2.0.

Testing

npm test        # 72 tests, node's built-in test runner, no external services
npm run check    # confirms the generated manifest is current
npm run invariants   # INVARIANTS.md holds against the suite (invariants/1)

Every exported function has direct test coverage, including every reachable DENY code, every ESCALATE impact tier, ledger isolation across chains and UTC-day boundaries, and the guardSend adapter's reserve/release-on-failure behaviour.

Security

See SECURITY.md for the threat model and how to report a vulnerability. In short: this package never holds a key, never signs, and never calls a network — it is a pure decision function over data you already have. Its entire security surface is "does grade() return the right verdict," which is exactly what the test suite checks.

Provenance

Extracted from the policy engine built for Flashy Wallet's own agent-facing envelope grading, and open-sourced because the problem it solves — spending limits for an automated caller with a wallet — belongs to every team building on WDK, not just to us. See CHANGELOG.md.

Contributing

See CONTRIBUTING.md.

⚡ The Strike

This README commits to a secret, the way grade() commits to a verdict — before anything is revealed, and checkable by anyone after:

sha256: 6124a73d8fb7b9825992bebeb99e1ff98ce07a202a53be51e4ccd7964e48d509

The preimage is already on this page — one exact sentence from "Design principles," above. Recover it, hash it yourself (never trust, verify — that includes us), and open an issue titled ⚡ STRIKE containing the sentence. First verified striker per release gets their name in STRIKERS.md — the only file in this repository that's append-only by tradition rather than by code.

No prize, no token. A spending-policy layer has enough of those jokes already.

License

Apache-2.0 © 2026 Flashy Labs


Built by Flashy Labs, the mesh platform for organisations' agents. If something here is broken, unclear, or just interesting, open an issue — we read them.

About

⚡ A spending-policy layer for wallets on Tether's WDK: per-agent envelopes graded before anything signs — asset, destination, per-transaction and daily caps. Three verdicts, always with a reason: ALLOW, ESCALATE, DENY. Apache-2.0 — npm install @flashy/wdk-policy-guard. The policy is open; the money it guards is not.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages