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")

  1. All amounts are integer cents. No floats anywhere on the wire.
  2. Every rejection names the deciding party — rejected_by: "bank" | "merchant" plus a stable code and a human message. The wallet never shows an anonymous error.
  3. 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.
  4. 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.
  5. 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.


Rendered from transaction_data (type generic_mandate), modelled on the PaSO payment consent already in EUDIPal:

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)