Scoped Mandate — Interface Specification (PoC)¶
Status: live prototype, 2026-10-10. Prepared for the Joint Agentic PoC sync (Google / EC / BMDS / France Identité / Sparkasse). German implementation notes: scoped-mandate.md. Business brief: SCOPED_MANDATE_EUDIPal.md.
0. One-page overview¶
An AI shopping agent buys on behalf of a user after one strong customer authentication (SCA). Four parties, four clearly separated responsibilities:
| Party | Holds | Decides | Does not see |
|---|---|---|---|
| Wallet (EUDIPal, iOS) | the user's bank credential, the device SCA | nothing about payments | — |
| Bank (Sparkasse mandate service) | the mandate, spend counters, agent public key | payment scope: per-transaction limit, total, validity, merchant whitelist, currency, agent identity | basket contents (only hashes) |
| Merchant | catalog, merchant signing key | functional scope: allowed/excluded categories, age restriction | the user's bank data |
| Agent (runs inside the wallet app, own identity + key) | its own P-256 key (Secure Enclave), an operator credential | nothing — it asks, assembles, signs and reports | — |
Flow in one sentence: the agent requests a mandate → the bank returns transaction_data → the wallet shows them and the user authenticates once (SCA) → the bank activates the mandate → for each purchase the agent gets a signed merchant attestation and sends a signed payment order to the bank → the bank enforces the payment scope and settles (SCT Inst under SPAA Dynamic Recurring Payment) → the user can revoke at any time.
Live endpoints (public, no auth, Frankfurt / europe-west3):
| Service | Base URL | Discovery | Human view |
|---|---|---|---|
| Bank — Mandate Service | https://bank.s-you.me |
/.well-known/mandate-service |
/dashboard |
| Merchant Service | https://shop.s-you.me |
/.well-known/merchant-service |
/dashboard |
Everything below is implemented and exercised end-to-end by the iOS app (Swift), by Python smoke tests and by a live integration test.
Machine-readable: OpenAPI 3.1 for both services — mandate-service.yaml, merchant-service.yaml; each live service also serves its own at /openapi.yaml and links it from discovery ("openapi"). Rendered reference: https://docs.s-you.me/api/mandate-service · https://docs.s-you.me/api/merchant-service.
Design principles (the "guard rails")¶
- All amounts are integer cents. No floats anywhere on the wire.
- Every rejection names the deciding party —
rejected_by: "bank" | "merchant"plus a stablecodeand a human message. The wallet never shows an anonymous error. - The wallet hashes what it displays. The SCA proof carries
base64url(SHA-256(canonical(transaction_data)))computed by the wallet over the data it rendered, not a hash the bank sent. - The agent has its own key, in its own Secure Enclave scope, separate from wallet credential keys and from the user's keys. The bank binds the mandate to that key and verifies every payment order's signature.
- Mocks with swappable interfaces. Bank and merchant are Flask services with Firestore; the Swift app talks to them through
MandateServiceProtocol/MerchantServiceProtocol. Phase 2 replaces them with the PaSO reference implementation and a UCP endpoint without touching the app layer.
Sparkasse requirements (2026-10-10) and where they land¶
| Requirement | Where |
|---|---|
| Agent and platform operator identify themselves to the bank | agent_credential in the mandate request; bank verifies hash and key binding (§1.2) |
| Mandate runs under SPAA — Dynamic Recurring Payment | payment_scope.scheme = "spaa_drp" (§1.2, §4) |
| Customer can revoke at any time in the bank app | POST /mandates/{id}/revoke, idempotent; dashboard button; wallet (§1.7) |
| Settlement is an SCT Inst transaction | payment_scope.rail = "sct_inst"; every receipt carries settlement with an end-to-end id (§1.4) |
1. Interface: Bank — Mandate Service¶
Base URL https://bank.s-you.me. JSON only. Dates are ISO-8601 UTC with second precision (2026-10-10T15:49:57Z). Keys are snake_case.
1.1 Discovery — GET /.well-known/mandate-service¶
{
"service": "mandate-service",
"version": "2026-10-10",
"display_name": "Sparkasse (Mock)",
"transaction_data_type": "generic_mandate",
"amounts": "integer cents",
"scheme": "spaa_drp",
"settlement_instrument": "sct_inst",
"endpoints": { "mandate_requests": "/mandate-requests", "authorize": "/mandates/authorize",
"payments": "/payments", "mandates": "/mandates", "mandate": "/mandates/{mandate_id}",
"activity": "/mandates/{mandate_id}/activity", "revoke": "/mandates/{mandate_id}/revoke" },
"payment_check_order": ["mandate_exists","status_active","validity_window","agent_matches",
"agent_proof_valid","currency","merchant_allowed","per_transaction_limit",
"total_limit","attestation_present"],
"rejection_codes": ["mandate_not_found","mandate_revoked","mandate_expired","mandate_exhausted",
"mandate_not_yet_valid","agent_mismatch","agent_proof_invalid","limit_exceeded",
"total_exceeded","merchant_not_allowed","attestation_missing","currency_mismatch",
"authorization_invalid","agent_identity_invalid"]
}
payment_check_order is part of the contract: the bank stops at the first failing check, so a client can predict which rejection it will see.
1.2 Request a mandate — POST /mandate-requests (Flow B, step 2)¶
The agent asks for a mandate. Nothing is active yet — the bank mints a mandate_request_id and the transaction_data the wallet must display and hash.
Request:
{
"agent": {
"agent_id": "agt_3f9c2e1b7a4d5c60",
"operator_legal_person": "digitallabor.berlin GmbH",
"ebw_credential_hash": "sha256:9d1e…"
},
"agent_credential": {
"agent_id": "agt_3f9c2e1b7a4d5c60",
"agent_display_name": "EUDIPal Einkäufer",
"operator_legal_person": "digitallabor.berlin GmbH",
"operator_register_id": "HRB 000000 B (Mock)",
"issued_at": "2026-10-10T12:00:00Z",
"agent_public_key": "<base64url SEC1 0x04||X||Y>"
},
"agent_public_key_jwk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…", "kid": "agt_3f9c2e1b7a4d5c60" },
"payment_scope": {
"currency": "EUR",
"max_per_transaction": 6000,
"max_total": 30000,
"valid_from": "2026-10-10T13:48:57Z",
"valid_until": "2026-11-09T13:48:57Z",
"merchant_whitelist": ["merchant_schwarz_demo"],
"merchant_categories": ["5411"],
"scheme": "spaa_drp",
"rail": "sct_inst"
},
"functional_scope": {
"allowed_categories": ["groceries", "household"],
"excluded_categories": ["alcohol", "tobacco", "electronics"],
"age_restricted_allowed": false
},
"creditor_display": "Sparkasse – Mandat für Agent ‹EUDIPal Einkäufer›"
}
Bank-side identity checks (requirement 1): agent_credential.agent_id and operator_legal_person must equal the short form in agent; agent.ebw_credential_hash must equal sha256:<hex> over the canonical JSON of agent_credential; agent_credential.agent_public_key (SEC1) must equal the point in agent_public_key_jwk. Any mismatch → 422 agent_identity_invalid. Phase 2 adds verification of the EBW issuer signature on the credential.
Response 201:
{
"mandate_request_id": "mrq_7b1f…",
"transaction_data": {
"type": "generic_mandate",
"mandate_request_id": "mrq_7b1f…",
"payment_scope": { …as requested… },
"functional_scope": { …as requested… },
"agent": { …short form… },
"creditor_display": "Sparkasse – Mandat für Agent ‹EUDIPal Einkäufer›"
},
"transaction_data_hash": "<base64url SHA-256 of canonical transaction_data>",
"expires_in": 900
}
transaction_data is the SCA object (PaSO generic mandate). It deliberately does not include the JWK or the full credential — only what the user must see. The wallet recomputes transaction_data_hash itself; the field is there for cross-checking only (the iOS client fails loudly if they differ).
1.3 Authorize — POST /mandates/authorize (Flow B, step 5)¶
After the SCA the wallet (or, in Phase 2, the PaSO verifier) hands the bank the proof.
{
"mandate_request_id": "mrq_7b1f…",
"transaction_data_hash": "<base64url SHA-256, computed by the wallet>",
"amr": ["hwk", "bio_strong", "face"],
"authorized_at": "2026-10-10T13:49:12Z"
}
The bank checks that the request exists and has not expired (TTL 900 s), that the hash equals its own, and that amr is non-empty. Response 201: the mandate mirror (see §1.6), status active. Rejections: authorization_invalid.
Phase 1 uses path (a): the wallet performs device SCA locally (Face ID / Touch ID / passcode with the mandate text as the reason) and posts this proof directly. Phase 2 uses path (b): the bank's PaSO verifier issues an OpenID4VP request with this transaction_data; the wallet's KB-JWT carries transaction_data_hash and amr; the verifier calls authorize internally. The hash construction is bit-identical to the PaSO payment path already shipped in EUDIPal.
1.4 Pay — POST /payments (Flow C, steps 3–4)¶
The agent's payment order. No basket, only hashes.
{
"mandate_id": "mnd_ad62…",
"agent_id": "agt_3f9c2e1b7a4d5c60",
"merchant_id": "merchant_schwarz_demo",
"amount": 706,
"attestation_hash": "sha256:…",
"basket_hash": "sha256:…",
"agent_proof": "<base64url ES256 raw r||s>"
}
agent_proof = ES256 with the agent key over the canonical JSON of all fields except agent_proof. The bank verifies it against the JWK stored with the mandate — so a stolen mandate_id is useless without the agent's Secure Enclave key.
Checks in payment_check_order; first failure → 422 with rejected_by: "bank". Success 201:
{
"payment_id": "pay_42455a0c…",
"mandate_id": "mnd_ad62…",
"merchant_id": "merchant_schwarz_demo",
"amount": 706,
"remaining_total": 29294,
"attestation_hash": "sha256:…",
"executed_at": "2026-10-10T15:49:58Z",
"rail": "sct_inst",
"settlement": {
"instrument": "sct_inst",
"scheme": "spaa_drp",
"end_to_end_id": "E2E70213E28DB764F21874C",
"status": "simulated"
}
}
settlement.status is simulated in the mock — no money moves. The fields are the ones a real SCT Inst execution under SPAA-DRP would carry.
1.5 Revoke — POST /mandates/{mandate_id}/revoke (Flow D)¶
No body. Idempotent. Returns the mandate mirror with status: "revoked" and revoked_at. Every subsequent payment is rejected with mandate_revoked. Available to the wallet, the bank app and the dashboard — requirement 3.
1.6 Mirror — GET /mandates, GET /mandates/{mandate_id}¶
Read-only view for the wallet ("the bank holds the mandate; the wallet shows the bank's state"). The mirror never includes agent key material.
{
"mandate_id": "mnd_ad62…",
"status": "active | revoked | exhausted | expired",
"principal": { "credential_ref": "sparkasse_credential_id" },
"agent": { "agent_id": "…", "operator_legal_person": "…", "ebw_credential_hash": "sha256:…" },
"agent_credential": { …public identity of agent + operator… },
"payment_scope": { … },
"functional_scope": { … },
"spent_total": 706,
"created_at": "2026-10-10T13:49:12Z",
"revoked_at": null,
"authorization": { "mandate_request_id": "…", "transaction_data_hash": "…", "amr": ["hwk","bio_strong","face"], "authorized_at": "…" }
}
1.7 Activity log — GET /mandates/{mandate_id}/activity¶
Newest first. Success and rejections are logged, so the user can see both.
[
{ "entry_id": "…", "mandate_id": "mnd_…", "at": "2026-10-10T15:49:59Z", "merchant_id": "merchant_schwarz_demo", "amount": 100,
"outcome": { "rejected": { "by": "bank", "code": "mandate_revoked", "message": "Das Mandat wurde widerrufen." } } },
{ "entry_id": "…", "mandate_id": "mnd_…", "at": "2026-10-10T15:49:59Z", "merchant_id": null, "amount": null, "outcome": { "revoked": {} } },
{ "entry_id": "…", "mandate_id": "mnd_…", "at": "2026-10-10T15:49:58Z", "merchant_id": "merchant_schwarz_demo", "amount": 5720,
"outcome": { "executed": { …payment receipt incl. settlement… } } },
{ "entry_id": "…", "mandate_id": "mnd_…", "at": "2026-10-10T15:49:57Z", "merchant_id": null, "amount": null, "outcome": { "authorized": {} } }
]
1.8 Errors¶
HTTP 422 { "error": { "rejected_by": "bank", "code": "limit_exceeded", "message": "Der Betrag übersteigt das Limit je Einkauf." } }
HTTP 400 { "error": { "rejected_by": null, "code": "bad_request", "message": "field amount: expected int" } }
2. Interface: Merchant Service¶
Base URL https://shop.s-you.me. Shaped so that a UCP endpoint can replace it in Phase 2 (the UCP discovery and checkout paths from the earlier milestone remain available on the same service).
2.1 Discovery — GET /.well-known/merchant-service¶
Returns merchant_id, display_name, categories, endpoints, rejection_codes and the signing key:
"signing_key": { "kty": "EC", "crv": "P-256", "alg": "ES256", "use": "sig", "kid": "merchant-demo-1", "x": "…", "y": "…" },
"attestation_signature": "ES256 raw r||s base64url over canonical JSON of the attestation without the 'signature' field"
The private key exists only in Google Secret Manager; it is never in the repository or on disk.
2.2 Catalog — GET /catalog?q=&category=, GET /catalog/{item_id}¶
{ "merchant_id": "merchant_schwarz_demo",
"items": [ { "item_id": "milk-1l", "name": "Vollmilch 1 l", "category": "groceries", "price_cents": 129, "age_restricted": false } ] }
21 items across groceries, household, alcohol, tobacco, electronics; three are age-restricted. Unknown item → 422 unknown_item.
2.3 Attest — POST /attest (Flow C, step 2)¶
The agent submits item ids and quantities only; the merchant knows prices and categories.
{
"mandate_id": "mnd_ad62…",
"basket": { "merchant_id": "merchant_schwarz_demo",
"lines": [ { "item_id": "milk-1l", "quantity": 2 }, { "item_id": "bread", "quantity": 1 } ] },
"functional_scope": { "allowed_categories": ["groceries","household"], "excluded_categories": ["alcohol","tobacco","electronics"], "age_restricted_allowed": false },
"agent": { …agent_credential, see §3… }
}
Check order (contract): unknown_merchant → empty_basket → agent_not_verified → per line: unknown_item → category_excluded → category_not_allowed → age_restricted_not_allowed. First failure → 422 with rejected_by: "merchant" — and no attestation, hence no payment order can be built.
Success 201:
{
"attestation": {
"attestation_id": "att_…",
"mandate_id": "mnd_ad62…",
"merchant_id": "merchant_schwarz_demo",
"basket_hash": "sha256:…",
"conforms_to_functional_scope": true,
"checks": ["categories_ok", "no_exclusions", "no_age_restricted"],
"amount": 507,
"issued_at": "2026-10-10T15:49:58Z",
"signature": "<base64url ES256 raw r||s>"
},
"attestation_hash": "sha256:…",
"basket_hash": "sha256:…",
"amount": 507
}
The iOS client re-derives attestation_hash and verifies signature against the discovery key before it builds the payment order. attestation_hash is what goes to the bank — the bank never sees the basket.
2.4 Merchant view — GET /dashboard, GET /events¶
Human view of what the merchant sees and decides: every basket check (agent, lines with the offending items highlighted, functional scope of the mandate, outcome with code and message) and the catalog. /events is the same log as JSON. In-memory only — the merchant persists nothing about users or banks.
3. Interface: Agent identity and agent proof¶
The agent is a first-class party with its own identity, separate from the user.
| Element | Phase 1 (now) | Phase 2 |
|---|---|---|
| Key | P-256 in the iPhone Secure Enclave, alias eudipal.scoped-agent.signer.v1, no biometry gate (the SCA is the mandate, not each payment) |
same |
agent_id |
agt_ + first 16 hex of SHA-256(SEC1 public key) — derived from the key, so identity and key cannot drift |
same |
| Credential | agent_credential self-issued by the app with operator legal person and register id (mock EBW) |
Legal-person credential from the European Business Wallet, EBW-signed |
| Verification by bank | short form ↔ credential, ebw_credential_hash, key ↔ JWK |
plus EBW issuer signature |
| Verification by merchant | structural (agent id, key, operator present) | plus EBW issuer signature |
ebw_credential_hash = sha256:<hex> over the canonical JSON of agent_credential. agent_proof (per payment) = ES256 raw over the canonical JSON of the payment order without agent_proof. Reinstalling the app creates a new key, hence a new agent, hence existing mandates must be re-granted — intentional.
4. Interface: Wallet consent and SCA¶
Rendered from transaction_data (type generic_mandate), modelled on the PaSO payment consent already in EUDIPal:
- who acts: agent display name, operator, register id, agent id
- what the bank enforces: per-transaction limit, total, validity, merchant whitelist, scheme (SPAA-DRP), settlement (SCT Inst)
- what the merchant enforces: allowed/excluded categories, age restriction
- the user's bank credential as the hero card
On confirm the device authenticates the user (Face ID / Touch ID / passcode) with the mandate text as the visible reason, e.g. "Mandat für ‚EUDIPal Einkäufer': bis 60,00 € je Einkauf, insgesamt 300,00 €, bei merchant_schwarz_demo, gültig bis 09.11.2026." Cancelling returns to the consent without any call to the bank. amr follows the PaSO mapping (["hwk","bio_strong","face"], fpt/iris for Touch/Optic ID, ["pin","hwk"] for passcode).
The wallet stores nothing about the mandate. It shows the bank's mirror (§1.6) and can revoke (§1.5).
5. Conventions shared by all parties¶
| Topic | Rule |
|---|---|
| Amounts | integer cents, field names amount, max_per_transaction, max_total, price_cents, spent_total, remaining_total |
| Keys / dates | snake_case; ISO-8601 UTC, seconds, Z |
| Canonical JSON | keys sorted, no whitespace, UTF-8, / not escaped — identical in Swift (JSONEncoder sortedKeys) and Python (json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)) |
sha256:<hex> |
basket_hash, attestation_hash, ebw_credential_hash |
transaction_data_hash |
base64url(SHA-256(canonical)), no padding — same construction as the PaSO payment path |
| Signatures | ES256, raw r‖s (64 bytes), base64url; same format for agent_proof and merchant signature |
| Errors | 422 {"error":{"rejected_by":"bank|merchant","code":…,"message":…}}; codes are stable identifiers mirrored 1:1 in the Swift enums |
6. Phase 2 — what gets swapped, what stays¶
| Component | Now | Phase 2 | Interface impact |
|---|---|---|---|
| Bank mandate service | Flask + Firestore mock | PaSO reference implementation / Sparkasse backend | MandateServiceProtocol stays; base URL or adapter changes |
| SCA | wallet-local device auth, proof posted by the wallet (path a) | OpenID4VP request from the bank's PaSO verifier with the same transaction_data, KB-JWT with transaction_data_hash + amr (path b) |
consent view unchanged; transaction_data comes from the JAR instead of the receipt |
| Merchant service | Flask mock | UCP endpoint | MerchantServiceProtocol stays; discovery via UCP |
| Agent credential | self-issued mock | EBW legal-person credential | same fields plus issuer signature; bank and merchant add signature verification |
| Settlement | simulated | SCT Inst under SPAA-DRP | receipt shape unchanged, settlement.status becomes real |
7. Try it in two minutes¶
# discovery
curl -s https://bank.s-you.me/.well-known/mandate-service | jq .
curl -s https://shop.s-you.me/.well-known/merchant-service | jq .signing_key
# catalog
curl -s 'https://shop.s-you.me/catalog?q=milch' | jq .
# full bank flow B/C/D with a real P-256 agent key (29 checks)
uv run --python 3.12 --with 'cryptography==43.0.3' python tools/mock-bank/smoke_test.py https://bank.s-you.me
# merchant: catalog, attestation signature, functional-scope rejections (20 checks)
uv run --python 3.12 --with 'cryptography==43.0.3' python tools/mock-merchant/smoke_test.py https://shop.s-you.me
Then open https://bank.s-you.me/dashboard — every mandate, limit bar, SCT Inst reference and rejection from the run is there, with the deciding party on each line.
In the app (TestFlight build ≥ 751): Chat → "Kauf mir 2 Milch, Brot und Spülmittel" → consent sheet → Face ID → purchase receipt. "Und ein Bier dazu" → the merchant rejects, the agent says who and why. "Was hat der Agent gekauft?" → the bank's activity log. "Widerrufen" → revoked; the next purchase is rejected by the bank.
8. Source map¶
| Part | Location |
|---|---|
| Bank mock, dashboard, smoke test, deploy | tools/mock-bank/ |
| Merchant mock, smoke test, deploy | tools/mock-merchant/ |
| Load balancer / domains | tools/setup-lb.sh, tools/DEPLOY.md |
| Swift types, protocols, errors | BankingPal-Pocket/Sources/ScopedMandate/Models, …/Services/ScopedMandateProtocols.swift |
| HTTP clients | …/Services/HTTPMandateService.swift, HTTPMerchantService.swift |
| Agent identity, agent, tools | …/Agent/ |
| Consent / SCA | …/Consent/ |
| Tests | Tests/ScopedMandate/ (51 unit tests, 1 live test) |