The AP2 instance of OP Crossrail — an agent arriving with a Google AP2 (Agent Payments Protocol) Open Payment Mandate resolves against the same shared policy core, and the same shared cross-rail spend ledger, as the x402, L402/Lightning, Tether-WDK and Fireblocks instances. Verify the SD-JWT mandate, normalize its constraints fail-closed, enforce, and debit the shared budget at authorize time.
Co-location contract (read before relying on the cross-rail budget): the cross-rail ledger is a local append-only file with no cross-process locking. Every adapter sharing a budget MUST be handed the SAME path IN THE SAME PROCESS. Different paths give each rail its own budget (the budget multiplies); a shared path across processes races and under-counts. Neither of these fails closed — verify co-location in your deployment. A missing path does fail closed: with no
crossRailLedgerPathset, this engine denies rather than authorize a spend it cannot record. An allowed but unrecorded AP2 leg would under-count the shared budget that the x402 and WDK gates price against, so those rails would over-spend a budget the principal set.crossRailLedgerPathis therefore effectively required.
AP2 (v0.2.0, FIDO-governed) expresses what a user authorized an agent to buy. It is settlement-agnostic and does not itself enforce: the mandate is evidence that networks, issuers and processors consult downstream. This engine gives it a pre-execution enforcement point: a proposed payment is evaluated against the mandate's own constraints before anything moves, and an allowed AP2 leg consumes the same rolling budget as the agent's USDT, sats and x402 legs.
The e2e suite includes an oracle-gated case driven by the AP2 reference SDK itself: a mandate created by Google's Python MandateClient authorizes here (and denies over-ceiling here). Verification of inbound tokens is handled by @observer-protocol/sd-jwt-substrate, which round-trips against the same SDK in both directions.
| AP2 constraint | Disposition |
|---|---|
payment.amount_range |
Enforced — max via the core per-transaction ceiling; min via a local exact pre-check |
payment.allowed_payees |
Enforced — core counterparty allow-list on the payee id |
payment.allowed_payment_instruments |
Enforced when the proposal declares its instrument; a proposal with no declared instrument denies |
payment.execution_date |
Enforced — local exact window check |
payment.reference |
Enforced — the caller supplies the checkout digest; missing or mismatched chain link denies |
payment.budget |
Denies (deliberate). It is a lifetime cap across occurrences; mapping it onto a rolling window would under-enforce, and wrongful acceptance is worse than wrongful rejection. Waits on lifetime accounting. |
payment.agent_recurrence |
Denies (deliberate) — occurrence counting is stateful and not built. Exception: ON_DEMAND with no max_occurrences restricts nothing and passes with a note. |
payment.allowed_pisps |
Denies (deliberate) — a self-declared PISP identity is not worth matching; waits on attested PISP identity. |
| anything else | Denies — AP2's own rule: any unknown constraint MUST be treated as failing. |
Nothing is silently ignored. A restriction this engine cannot faithfully enforce is a restriction the principal counted on, so it denies with a named reason.
There is also a floor. A mandate must bound value: if the normalized mandate carries no per-transaction ceiling, it denies. An empty constraint list, a list whose every entry restricts nothing, and a payee list with no amount bound all land there. An unconstrained mandate is not an unlimited mandate, so payment.amount_range with a max is required before this engine authorizes anything.
emitAp2Mandate expresses an OP delegation's mandate as an AP2 Open Payment Mandate (ES256 SD-JWT, agent cnf bound). The emit discipline mirrors the verify-side normalize discipline: every OP restriction either maps onto an AP2 constraint with identical-or-stricter semantics, or the emit refuses with a named reason — velocity caps, temporal windows, geographic restrictions, block-lists and lossy amounts all refuse rather than silently drop. Dropping a restriction on emit would hand the agent more authority than the principal signed. One deliberate stricter-direction mapping: OP's rolling-24h crossRailBudget emits as AP2's lifetime payment.budget — a verifier honoring it authorizes less than the OP mandate would, never more.
OP's AP2 signing key is a dedicated P-256 JWK published at a JWKS endpoint — deliberately not a key in the DID document. issueAp2KeyLinkage / verifyAp2KeyLinkage implement the binding: an Ap2KeyLinkageCredential issued by the DID controller, signed eddsa-jcs-2022 with a key in the DID document's assertionMethod, whose subject is the exact public JWK. A relying party that trusts did:web:observerprotocol.org resolves the DID document once, verifies the linkage, and pins the JWK as its AP2 trust root — callback-free, offline-verifiable, and AP2 itself never has to resolve a DID. Binding a private key is refused at issue time; a linkage signed by a non-assertionMethod key is refused at verify time.
The full chain is a test, not a diagram: DID → linkage → trusted JWK → an emitted mandate authorizes through the same engine that enforces inbound AP2 (and a stranger's key does not).
Who may issue mandates is the deployment's decision: the engine takes the trusted issuer's public JWK (or resolver) from config and never fetches keys itself. v1 verifies root tokens; the AP2 SDK's ~~-joined delegation chains are rejected explicitly rather than half-verified.
On allow, the payment is recorded into the shared CrossRailLedger (rail ap2:<currency>, minor units) at authorize time. Any OP crossRailBudget evaluated by the other engines then prices that AP2 spend against the mandate's principal-attested rates — one budget, every rail, including this one.
@observer-protocol/sd-jwt-substrate is not yet on npm; the dependency is a file: link to a sibling checkout of op-policy-engine. Clone both under one parent directory. The npm publish of both packages is a pending launch decision.
npm install && npm test
Five hermetic tests run everywhere. The sixth (reference-SDK interop) needs the oracle env — see the substrate README for the two-line setup — and skips loudly without it.