CommBank ID + income

Design · docs/CONTRACTS.md

Interface contracts

Every component builds against these. Change them here first, then in code.

Participants and hostnames

Participant Folder Railway service(s) Local port
Mock CDR Register participants/register register 8084
Westpac (DH) participants/dh-westpac dh-westpac-pf, dh-westpac-api, dh-westpac-consent PF 9031 / API 7101 / consent 7201
NAB (DH) participants/dh-nab dh-nab-pf, dh-nab-api, dh-nab-consent PF 9032 / API 7102 / consent 7202
ANZ (DH) participants/dh-anz dh-anz-pf, dh-anz-api, dh-anz-consent PF 9033 / API 7103 / consent 7203
CBA as ADR participants/adr-cba adr-cba-pf, adr-cba-cdr-kit PF 9034 / cdr-kit 7300
CBA issuer participants/cba-issuer offers-api, offers-gateway (PAZ), cba-core-banking (v1.9) 7400 / 7401 / 7800
Pod runtime packages/pod-runtime pod-runtime 7500
Redis - redis 6379
ZK verifier apps/zk-verifier zk-verifier 7600
QR landing apps/qr-landing qr-landing 7700

All base URLs come from env vars named <SERVICE>_URL (e.g. POD_RUNTIME_URL, ADR_CDR_KIT_URL, REGISTER_URL). Never hardcode.

Data holder brand ids (Register dataHolderBrandId): westpac, nab, anz. ADR: legal entity Commonwealth Bank of Australia, brand CommBank, accreditation number ADRBNK000001, software product id cba-income-verify.

Customers

data/customers.json is the single source. Each participant's seed is generated from it by data/generate-seeds.mjs. Mobiles are placeholders until the real list arrives (format 04xxxxxxxx). Fixed OTP everywhere: 000789.

Identity of the CBA customer

CBA PingFederate (adr-cba-pf) is the CBA OP. Access tokens are JWTs (RS256/PS256) with sub = CBA customer id (e.g. cba-cust-001), iss = CBA_ISSUER env, JWKS at CBA_JWKS_URL. For local dev without PF, adr-cba/cdr-kit exposes POST /dev/token {sub} that mints an equivalent token from a dev key (disabled when NODE_ENV=production unless DEV_TOKENS=1).

Redis Streams

Stream cdr.arrangements (written by adr-cba cdr-kit, consumer group pods):

// every event: { "type", "eventId" (uuid), "occurredAt" (ISO), "customerSub", "cdrArrangementId", "dataHolderBrandId" }
{ "type": "ArrangementEstablished",
  "scopes": ["bank:accounts.basic:read","bank:transactions:read","common:customer.basic:read"],
  "consentType": "once-off" | "ongoing",
  "sharingExpiresAt": "ISO" | null,          // null for once-off
  "useConsents": ["income-verification","income-proof"],
  "deletionElection": "delete" | "deidentify",
  "purpose": "rental-income-verification",
  "listingId": "string|null" }
{ "type": "ArrangementRevoked", "revokedBy": "consumer" | "data-holder" | "adr" }
{ "type": "ArrangementExpired" }

Redis Streams store field/value pairs: publish a single field event holding the JSON string.

Stream pod.events (written by pod-runtime; read by apps via SSE/polling, and for the demo console):

{ "type": "PodCreated" | "PodLoading" | "PodLoaded" | "IncomeReady" | "ProofIssued"
        | "ArrangementDataDeleted" | "PodDestroyed" | "PodFailed",
  "eventId", "occurredAt", "customerSub", "cdrArrangementId"?, "step"?, "detail"? }

Tokens are never placed on a stream.

Token manager (adr-cba cdr-kit, internal)

POST {ADR_CDR_KIT_URL}/internal/arrangements/{cdrArrangementId}/access-token Header Authorization: Bearer {INTERNAL_API_KEY} → 200 { "access_token", "token_type": "«redacted»"|"DPoP", "expires_in", "dataHolderBrandId", "resourceBaseUri" } → 404 unknown arrangement, 410 revoked/expired. The refresh token stays with the ADR (PF data-in / cdr-kit store), never leaves it.

Pod runtime HTTP API (POD_RUNTIME_URL)

Auth: either Authorization: Bearer {INTERNAL_API_KEY} (services: offers, MATTR claim source, PAZ) or a CBA access token whose sub must equal {sub} (the app).

Offers gateway (OFFERS_GATEWAY_URL) — what the app calls

POST /offers with CBA access token, body { "profile": "commbank-id" | "commbank-id-income", "listingId"? }

The app must NOT auto-follow the 302; it opens Location in ASWebAuthenticationSession with callback scheme cbaapp.

ADR consent start

GET {ADR_CDR_KIT_URL}/consent/start?return=…&session=… — the session is a short-lived signed handle (issued by the offers API) carrying the CBA sub, so the browser leg knows who the customer is without a cookie. Final redirect: cbaapp://consent-complete?status=success|cancelled|error&cdrArrangementId=….

Pod store

One encrypted SQLite per pod, object key pods/{sub}.db.enc in the bucket (POD_BUCKET_* env; local dev uses ./.pods/). Data key: random 256-bit, AES-256-GCM, wrapped with POD_MASTER_KEY; wrapped key stored in the pod manifest index (pods/{sub}.key). Destroy = delete both objects; deletion record kept in pods/_deleted/{sub}.json (no CDR data).

Amendments (v1.1, from the pod build)

  1. income.annualised is net pay annualised (after tax), e.g. Alex Chan ≈ 75,849 net vs 98,000 gross. Listing thresholds must be expressed in net terms.
  2. Pod status is additive: per arrangement retainUntil, endReason, endedAt, failure; a deletion block when the pod is destroyed. Step names are fixed human-readable strings ("Pod created", "Loading accounts", "Calculating income", "Preparing income proof").
  3. DELETE /pods/{sub}/arrangements/{id} → { status, podDestroyed, adrRevoke, deletionReceipt }.
  4. purpose is an allowlist: credential-issuance, offer-eligibility, consumer-view; anything else → 403 (audited).
  5. POST /pods/{sub}/proofs/income errors: 403 use_consent_missing, 409 not_ready, 422 threshold_not_met.
  6. The internal key may also be sent as x-api-key (for the MATTR claim source). SSE /pods/{sub}/events accepts ?access_token=.
  7. Token manager returns Bearer tokens to the pod (the pod can't mint DPoP proofs). Where a DH requires DPoP, adr-cba proxies the DH call instead.
  8. Pod JWS: typ: pod-commitment+jwt, payload {commitment, sub, iat}.
  9. pod-runtime runs as a single replica (in-memory actors).

Amendments (v1.2, from the iOS build)

  1. POST /dev/token → { access_token, token_type: "Bearer", expires_in }.
  2. GET {QR_LANDING_URL}/api/listings/{id} → { id, address, rentPerWeek, incomeThreshold /* annual, NET */, agency }.
  3. Proof sharing: the app uses the pod's verifierUrl from POST /proofs/income as-is; the pod owns the bundle encoding.
  4. Consent callback status values: success | cancelled | denied | error (denied = consumer declined at the data holder).
  5. AASA lists <TEAMID>.au.com.cba.hackathon.wallet for paths /rent/verify*.

Amendments (v1.3, from the ADR build)

  1. ADR brand id commbank. DH redirect URI: {ADR_CDR_KIT_URL}/cdr/callback. ADR JWKS: {ADR_CDR_KIT_URL}/.well-known/jwks.json — the Register must set ADR_JWKS_URI to this (its default is a Register dev key).
  2. session handle: HS256 JWT, secret OFFERS_SESSION_SECRET, claims {sub, listingId, return, exp}, lifetime ≤ 15 min, return must start with cbaapp://.
  3. CBA_JWKS_URL = {adr-cba-pf}/ext/cba/jwks with PF; {ADR_CDR_KIT_URL}/dev/jwks.json for dev tokens.
  4. Success redirect also carries dataHolderBrandId and listingId. Once-off arrangements expire after ONCE_OFF_USE_SECONDS (default 24h).
  5. Extra cdr-kit endpoints: GET /internal/arrangements/{id} (internal), GET /api/arrangements (app, CBA token).
  6. PingFederate version: Docker Hub no longer publishes 11.2 images; all PFs use 13.1.3.

Amendments (v1.4, from the issuer build)

  1. Pod state failed → offers returns 302 (start consent again).
  2. 302/409 bodies: { status, error, state }.
  3. MATTR claim source points at offers-api GET /claims/mattr (not pod-runtime): it merges identity from data/customers.json (CBA PF id_token carries only sub) with pod income, and answers MATTR's registration probe (no sub) with 200 {}. Must be publicly reachable by MATTR; auth x-api-key.
  4. MATTR has no issuer_state on auth-code offers: the customer is bound with request_parameters.login_hint = sub → CBA PF → sub → claim source.
  5. Offer URIs are inline (openid-credential-offer://?credential_offer=…); the app treats them as opaque.
  6. PAZ reads CBA_JWKS_BASE_URL + CBA_JWKS_PATH; keep PAZ policy tracing off (internal key travels in the policy request).

Amendments (v1.5, from the DH/Register build)

  1. private_key_jwt client assertions use header typ: client-authentication+jwt (PF 13.1 rejects JWT); aud = DH issuer or endpoint.
  2. DH access-token claims: sub = DH customerId, account_ids (space-delimited), cdr_arrangement_id, sharing_expires_at.
  3. DCR at {DH infosec}/register, client_id = software_id; signed request objects required at PAR. DCR state lives in each DH consent app (/data volume) — the ADR re-registers on invalid_client.
  4. Register authDetails.jwksEndpoint = each DH consent app's JWKS (it signs DH-initiated revocations to cdr-kit POST /arrangements/revoke).
  5. DH host ports are overridable: DH_<BRAND>_PF_PORT, DH_<BRAND>_PF_URL. In local compose server-side calls use dh-<brand>-pf:8080 while the issuer stays localhost.
  6. Demo listings (qr-landing): newtown-studio-7 $280/wk, surry-hills-123 $420/wk (default QR), bondi-beach-42 $520/wk; threshold = rent×52/0.30 on net income.

Amendments (v1.6, adr-cba rebuilt as an authentic Ping CDR Kit data-in participant)

Supersedes the cdr-kit parts of v1.1 §7, v1.3 §15–19 and the "Token manager" section. The hand-rolled Node cdr-kit is archived at archive/non-kit/adr-cdr-kit/.

  1. What adr-cba runs. The kit's data-in stack, each with its own server profile under participants/adr-cba/server-profiles/: PingFederate 13.0.3 (pf-cdr-au-data-in-modules: DataHolderSelector, callback, /arrangements/revoke), PingAccess 8.3.2 (pa-cdr-au-data-in-modules: CDRInjectDataHolderTokenRule), PingDirectory 11.0.0.4 (pd-cdr-au-data-in-modules: SCIM token store + refresh plugin), PingDataSync 11.0.0.4 (pds-cdr-au-data-in-modules: revocation at the DH, plus the CBA event pipe). The kit's tamatping/datain-configure-pf runs as a one-shot configure service (Register CA, network certs, DCR). adr-cba-web is a thin CBA layer: CX screens, the customer hand-off, the dashboard and /dev/token. It does no CDR protocol work.
  2. ADR public base (= CDR recipient_base_uri): ADR_PUBLIC_BASE_URL, local https://sso.commbank.localhost:9444 (kit PF, same name inside Docker on cdr). Register settings: ADR_BASE_URL = that, ADR_JWKS_URI = {ADR_PUBLIC_BASE_URL}/pf/JWKS (the kit PF's static OIDC keys = CBA's software product key), redirect {ADR_PUBLIC_BASE_URL}/ext/cdr/data-in/callback, DH→ADR revocation {ADR_PUBLIC_BASE_URL}/arrangements/revoke (kit servlet; DH-signed bearer JWT, aud = that URL).
  3. ADR_CDR_KIT_URL now means adr-cba-web (port 7300, unchanged). It still serves GET /consent/start?session&return (HS256 handle, v1.3 §16), the final redirect cbaapp://consent-complete?status=success|cancelled|denied|error&cdrArrangementId&dataHolderBrandId&listingId, POST /dev/token, GET /dev/jwks.json, GET /dashboard, GET /api/arrangements, POST /api/arrangements/{id}/stop, GET /internal/arrangements/{id}, POST /internal/arrangements/{id}/revoke. GET /cdr/callback, POST /arrangements/revoke and /.well-known/jwks.json move to the kit PF (§34).
  4. Token manager endpoint retired. POST /internal/arrangements/{id}/access-token no longer exists: DH tokens never leave the kit. The pod reads DH data through the kit's PingAccess:
    • token: «redacted»
    • data: GET {ADR_DATA_PROXY_URL}/dh/{dataHolderBrandId}/cds-au/v1/banking/... with Authorization: Bearer <that token>, X-CDR-Subject: {customerSub}, X-CDR-Context: cba-adr-web (ADR_CDR_CONTEXT) and the usual CDS x-v/x-fapi-* headers. The kit rule finds the arrangement for subject + DH + context + software id in PD, refreshes the DH token there if due, swaps the Authorization header and forwards over the kit's mTLS site authenticator. A customer without an active arrangement → 403.
    • pod-runtime env: ADR_DATA_PROXY_URL (local https://adr-cba-pa:3000, host https://localhost:7310), ADR_TOKEN_URL, ADR_POD_CLIENT_ID, ADR_POD_CLIENT_SECRET, ADR_CDR_CONTEXT. Without ADR_DATA_PROXY_URL the pod falls back to the legacy endpoint (dev fakes only).
  5. One active arrangement per customer per DH. The kit keys arrangements by subject + DH + context; a new consent with the same DH amends the existing arrangement (the kit sends its cdr_arrangement_id in the request object).
  6. Events come from PingDataSync, not application code: the CBA sync pipe watches the kit's token store and publishes to cdr.arrangements (same JSON schema, single event field, no tokens). ArrangementEstablished = a new kit token record; ArrangementRevoked.revokedBy = consumer/adr when CBA flagged it, otherwise data-holder (DH called the kit's revocation servlet); ArrangementExpired = the pipe's expiry sweep set the record to expired (ongoing: sharing period over; once-off: ONCE_OFF_USE_SECONDS, default 24 h, after consent). PDS env: CBA_EVENTS_REDIS_URL.
  7. Consent choices travel through the kit: the CBA layer sends them as tracked authorize parameters (use_consents, deletion_election, purpose, listing_id, consent_type, override_sharing_duration) and the kit stores them as arrangement metadata ("Metadata Params"). dataHolderBrandId in events is the kit DH record's Register brand id.
  8. CBA customer tokens: unchanged for now (/dev/token on adr-cba-web with CBA_JWKS_URL={ADR_CDR_KIT_URL}/dev/jwks.json, or the existing CBA OP PF participants/adr-cba/pingfederate on :9034 with /ext/cba/jwks). It is not yet folded into the kit PF.
  9. Ports (adr-cba): kit PF 9444 (admin 9934), PA engine 7310 (admin 7311), PD 7312, web 7300. All overridable (ADR_*_PORT). The pod reaches PA at https://localhost:7310 from the host or https://adr-cba-pa:3000 on the adr-cba network; both are PA virtual hosts and adrATM resource URIs. 42a. ADR TLS. The kit PF's runtime certificate is issued by the Register CA (kit PKI), so DHs can fetch {ADR_PUBLIC_BASE_URL}/pf/JWKS. Regenerate it (FORCE_TLS=1 scripts/gen-keys.sh) when the Register CA changes. 42b. Networking follows v1.7 §50: adr-cba maps cdrregister and sso|api.<brand>.localhost to host-gateway, and joins no shared network.

Amendments (v1.7, Register + Data Holders rebuilt as authentic Ping CDR Kit participants)

Supersedes v1.5 §27–31 and the DH/Register rows of the participants table. The Node Register, the Node DH kit inside PF, the Node consent app and dh-banking-api are archived under archive/non-kit/.

  1. Register = the kit's test harness (tamatping/cdr-register-testharness:20231123_1), participants/register/, host port 8084, container register, on cdr with the kit aliases cdrregister/mockregister. Its model is rendered at boot from participants/register/cache-templates/ (DH brands westpac, nab, anz; legal entity commonwealth-bank-of-australia, accreditation ADRBNK000001, brand commbank, software products cba-income-verify and the test-only cba-dh-smoke). SSAs are PS256, iss=cdr-register, signed by the harness key _GLOBAL_ (JWKS /.well-known/openid-configuration/JWKS; regenerated at each Register start, so DHs fetch it live). The harness also runs the CDR PKI: /public/download (CA), /public/sign (CSR → client/server cert). The CA persists in the register-cache volume.
  2. Register env for the ADR (all optional): ADR_BASE_URL (default https://sso.commbank.localhost:9444), ADR_JWKS_URI (default: the harness helper JWKS), ADR_REVOCATION_JWKS_URI, ADR_REDIRECT_URIS (comma-separated; default {ADR_BASE_URL}/ext/cdr/data-in/callback), ADR_RECIPIENT_BASE_URI, ADR_LOGO_URI, ADR_POLICY_URI, ADR_TERMS_URI, ADR_SOFTWARE_PRODUCT_STATUS (ACTIVE/INACTIVE; the DHs' PingDataSync re-reads status every 10 s). DH URLs: DH_<BRAND>_SSO_URL, DH_<BRAND>_API_URL, DH_<BRAND>_LOGO_URI. Note the kit sets the SSA org_name to the brand id (commbank).
  3. Each DH is a full kit stack, participants/dh-<brand>/: PingFederate 13.0.0 (pf-cdr-au-modules), PingAccess 8.3.2 (pa-cdr-au-modules), PingDirectory 10.3.0.4 (clients, grants, consents, customers), PingDataSync 10.3.0.4 (pds-cdr-au-modules: Register status cache, DH→ADR revocation), PingAuthorize 10.3.0.4 (the kit's "PingDataGovernance" API gateway policy), tamatping/agentless-consentapp, tamatping/mock-dh-apis, and the kit's tamatping/datain-configure-pf side-car. Profiles are generated from the vendored kit by deploy/kit/build-dh.mjs and committed per participant; server-profiles/KIT-CHANGES.md lists every deviation.
  4. DH public URLs (PingAccess is the only public entry point): https://sso.<brand>.localhost:<port> (issuer, PAR, authorize, token, DCR at /register, /data-holder/arrangements/revoke, JWKS /pf/JWKS), https://api.<brand>.localhost:<port>/cds-au/v1/banking/..., https://consent.<brand>.localhost:<port>. Ports: Westpac 9441, NAB 9442, ANZ 9443. The port is part of the issuer, so it is not freely overridable (change DH_PUBLIC_PORT in env/dh.env and the Register's DH_<BRAND>_*_URL together). On Railway each DH gets a real domain on 443.
  5. Trust profile = the kit's. DCR: POST {issuer}/register, Content-Type: application/jwt, signed with the software product key, carrying the Register SSA, over mTLS with a Register-CA-signed client cert (/public/sign). The kit issues client_id = dcr-sso-<UUID.nameUUIDFromBytes(software_id)> (CBA: dcr-sso-53f1b838-cba2-3a60-90de-4eba13dbe573) and allows one registration per software_id per DH (a second POST → 400 Duplicate registrations…); after a DH restart (PD is ephemeral) the ADR must register again. PAR (/as/par.oauth2) with a PS256 request object (claims.sharing_duration, claims.id_token.acr), PKCE S256, private_key_jwt; tokens are certificate-bound (cnf.x5t#S256), so every token, API and revocation call must use the same client cert. response_type=code id_token (id_token encrypted RSA-OAEP/A256GCM) as in the kit's tests. Revocation: POST {issuer}/data-holder/arrangements/revoke (cdr_arrangement_id, client assertion, mTLS) → 204.
  6. Login = kit identifier-first page (mobile or customer id) → OTP page (PF HTML Form adapter + LDAP PCV against PingDirectory; fixed demo code 000789) → kit agentless consent app. Consent = the kit consent app with a branded, CX-laid-out template (same model). Ineligible accounts = the kit's isOwned=false (shown greyed with a reason).
  7. Access-token subject is the customer's PingDirectory entryUUID (kit design, pseudonymous), = nameUUIDFromBytes(customerId), e.g. Alex @ Westpac 4cd4c135-0ca6-334d-9fbe-fe1b782f6916. Account ids are the seed ids (westpac-acc-…). Consented accounts are enforced by PingAuthorize (X-ACCOUNTS), not a token claim.
  8. mock-dh-apis quirks (kit image): /accounts/{id}/transactions returns the customer's whole transaction list (every item carries its real accountId) - consumers must filter by accountId / de-duplicate by transactionId. Balances are synthesised by the kit from the account id, not seeded. No common/customer.
  9. Networking. Participants do not share Docker networks with the DHs. Kit stacks reuse the same hostnames (pingfederate, pingaccess, …) and Compose adds service names as aliases on every network, so a shared network makes kit stacks resolve each other's servers. Cross-participant calls use the published host ports (as on Railway): containers map names with extra_hosts: ["<name>:host-gateway"]. The DHs map cdrregister/mockregister (Register, :8084) and sso.commbank.localhost (ADR, :9444). adr-cba must map sso.westpac.localhost, api.westpac.localhost, sso.nab.localhost, api.nab.localhost, sso.anz.localhost, api.anz.localhost → host-gateway in its PF, PA and PDS containers. The Register stays on cdr (alias cdrregister) for adr-cba.

Amendments (v1.8, Railway deployment of the kit participants)

  1. Railway hostnames (deploy/railway/README.md has the map). Each DH's PingAccess sits behind a Railway TCP proxy, with mTLS end to end. Its kit BASE_HOSTNAME is <brand>.<proxy-ip-dashed>.sslip.io and DH_PUBLIC_PORT is the proxy port. Westpac https://sso.westpac.66-33-22-234.sslip.io:47740, NAB https://sso.nab.66-33-22-224.sslip.io:49950, ANZ https://sso.anz.66-33-22-232.sslip.io:25120 (api./consent. likewise). The DH pages carry the CDR Register CA's certificate, so browsers warn.
  2. ADR on Railway: ADR_PUBLIC_BASE_URL = https://adr-cba-pf-production.up.railway.app (kit PF behind the Railway edge; ADR endpoints are TLS-only in the CDR). ADR_CDR_KIT_URL = https://adr-cba-web-production.up.railway.app. pod-runtime: ADR_TOKEN_URL = {ADR_PUBLIC_BASE_URL}/as/token.oauth2, ADR_DATA_PROXY_URL = https://adr-cba-pa-production.up.railway.app (also the adrATM resource URI and the PA vhost ADR_DATA_PROXY_HOST:ADR_DATA_PROXY_PORT; local default localhost:7310).
  3. adr-cba-web cookies are SameSite=None; Secure when ADR_WEB_URL is HTTPS, because the kit PF hands back to /pf/authn with a cross-site form POST. Plain-HTTP local dev keeps Lax.

Amendments (v1.9, income from CBA's own accounts plus CDR)

The income credential can now draw on the customer's own CommBank accounts and, optionally, on other banks through the CDR. Design rule: the pod is for CDR data only. CBA income is worked out from CBA's core banking (mock) and never enters a pod. The issuer adds the two at issuance. A CBA-only customer gets no pod. Each source signs its own income commitment, and the ZK income proof covers the total (§59), so CBA-only customers can prove income too.

  1. CBA core banking (mock) - participants/cba-issuer/core-banking, Railway cba-core-banking, port 7800, CBA_CORE_BANKING_URL. Data: the cba block of each customer in data/customers.json (accounts with CommBank product names, opening balances, income streams) → data/generate-cba-seed.mjs → participants/cba-issuer/core-banking/data/seed.json (deterministic, 12 months to 2026-09-30; dates shifted forward at runtime so the window ends today, as the DH mock APIs do). DH seeds are unaffected.

    • GET /accounts - CBA access token (CBA_JWKS_URL = CBA PF /ext/cba/jwks, CBA_ISSUER); the token's sub is the customer → 200 { "sub", "accounts": [ { "accountId", "displayName", "nickname"?, "productName", "productCategory", "maskedNumber", "bsb"?, "balance": { "current", "available", "currency" }, "receivesIncome": bool } ] }. receivesIncome = a regular income is paid into it (the app preselects these).
    • GET /accounts/{accountId}/transactions?limit= - CBA token, the account must be the caller's → { "accountId", "transactions": [CDS-shaped, newest first] }; another customer's account → 404.
    • GET /customers/{sub}/accounts - internal key (Authorization: Bearer {INTERNAL_API_KEY}), same body as /accounts.
    • POST /internal/income - internal key, { "sub", "accountIds": [...] } → 200 { "sub", "accountIds", "income": CbaIncome | null, "reason"?: "no_recurring_income" }; CbaIncome also carries commitment, commitmentSignature, kid (§59). 400 empty or foreign account ids, 404 unknown customer. It runs the pod's income aggregator (@cba/pod-programs/income, detectIncome) over the selected accounts' posted transactions, after removing transfers between the customer's own CBA accounts (CBA's ledger knows them). CbaIncome = the aggregator's result with source: "CBA" and accountIds: { annualised, currency, frequency, averageNetPay, employer, monthsObserved, confidence, source, accountIds, verifiedAt } (net, like §1).
  2. POST /offers for commbank-id-income takes incomeSources: { "cbaAccountIds": string[], "cdr": boolean, "addBank"?: boolean }. Omitted = the old behaviour ({ cbaAccountIds: [], cdr: true }). Decision (PingAuthorize policy on Railway, mirrored by offers-api's local PDP):

    Sources Pod Answer
    cdr: false not read permit → offers-api computes CBA income: 200 offer, or 422 {"error":"no_cba_income"}; cbaAccountIds empty → 400
    cdr: true created/loading/loaded 409 pod_loading
    cdr: true, addBank: true anything else 302 into ADR consent (connect another bank)
    cdr: true none/destroyed/failed 302 into ADR consent
    cdr: true income_ready permit → 200 (CBA income added when accounts were selected)

    The 200 body adds "incomeSources": { "cba": { "annualised", "frequency", "employer" } | null, "cdr": bool }. offers-api remembers the customer's selection (accounts, cdr, computed CBA income) for an hour, in memory, for the claim source.

  3. More than one other bank. Each extra bank is another consent (addBank: true → 302); the kit keeps one arrangement per customer per DH (§37). The pod sums income across its active arrangements (one income stream per DH): outputs/income → annualised = the sum; frequency, employer, dataHolderBrandId = the largest source; additive sources: [{ cdrArrangementId, dataHolderBrandId, annualised, frequency, employer }]. The commitment is Poseidon(sum, salt), with the salt in one anchor arrangement's compartment (the earliest active one with income-proof); proofs prove the sum. Pod GET /claims/mattr adds income_data_holders (comma-separated brand ids) and income_employers. Pod state is income_ready only when no active arrangement is still created/loading/loaded; a bank with no recurring income (failed) does not block the others.

  4. Claim source (offers-api GET /claims/mattr?sub=, income config) returns identity plus: income_total_annualised, income_annualised (= total, backward compatible), income_cba_annualised, income_cdr_annualised (0 when that source is absent), income_sources (display, e.g. "CommBank, Westpac via CDR"), income_source (CBA | CDR | CBA+CDR), income_frequency and income_employer (largest source), income_employers (all, ", "), income_verified_at, and per source income_commitment_cba (when CBA income) and income_commitment_cdr (when CDR income) - see §59. (income_commitment and income_commitment_scope, briefly used during this build, are gone.) With cdr: false the pod is never called. Without a remembered selection: CBA = the customer's receivesIncome accounts, CDR = included when the customer has a pod that isn't none/destroyed/failed (a loading pod passes its 409 through). No income from any source → 409 {"error":"no_income"}.

  5. Income proof (app): offered whenever the credential has income from any source; the listing threshold is checked against the total. When the total is below the threshold the app says so and makes no request. The app calls POST {CBA_CORE_BANKING_URL}/proofs/income (§59), not the pod.

  6. Total-income ZK proof. Pod-only-for-CDR still holds: no CBA data enters the pod, and nothing but an opening leaves it.

    • Commitments. Core banking: C_cba = Poseidon(cbaIncome, saltCba) over the selected accounts. saltCba = HMAC(CBA_COMMITMENT_SECRET, sub + sorted account ids) truncated to 248 bits, so the same accounts give the same commitment without storage; income is evaluated at a fixed point relative to the shifted seed, so it is stable. Signed ES256 with CBA_SIGNING_JWK, header typ: cba-commitment+jwt, payload { commitment, sub, iat }, JWKS at {CBA_CORE_BANKING_URL}/.well-known/jwks.json. Pod: C_cdr as today (§56, summed across banks; pod-commitment+jwt). A missing source is the zero commitment Poseidon(0, 0), unsigned.
    • Opening from the pod. POST {POD_RUNTIME_URL}/pods/{sub}/proofs/income-share - internal key only → 200 { annualIncome, salt, commitment, signature, kid, dataHolders } (the combined CDR figure). Needs the income-proof use consent (else 403 use_consent_missing); 404 no pod; 409 not_ready. Audited per arrangement as disclosure of income-commitment-opening (fields annualIncome, salt) to cba-income-proof. No accounts, transactions or other data leave the pod.
    • Circuit packages/zk-circuits/circuits/income_sum_gte.circom: private cbaIncome, cbaSalt, cdrIncome, cdrSalt; public [threshold, commitmentCba, commitmentCdr] (that order); both openings checked, amounts 32-bit, cbaIncome + cdrIncome ≥ threshold at 33 bits. ~1,170 constraints, demo-only beacon setup on 2^11 powers of tau; committed build-artifacts/verification_key_sum.json. income_gte (one commitment) is unchanged.
    • Prover = CBA core banking (the pod can't see CBA data; CBA-only customers have no pod). POST {CBA_CORE_BANKING_URL}/proofs/income - CBA access token, { threshold, listingId?, cbaAccountIds? } (default: the receivesIncome accounts) → 200 { v: 2, proof, publicSignals, threshold, commitments: { cba, cdr }, sources: { cba, cdr }, signatures: { cba?, cdr? }, kids: { cba?, cdr? }, listingId?, cdrNote?, verifierUrl }. cdrNote = no_pod | income_proof_consent_missing when the CDR side is left out. Errors: 400 bad threshold, 401, 409 pod_not_ready, 422 threshold_not_met | no_income, 502 pod unreachable. No amounts in the response.
    • Verifier (zk-verifier, CBA_CORE_BANKING_URL + POD_RUNTIME_URL): a bundle with v: 2 is checked against verification_key_sum.json; public signals must equal the stated threshold and commitments; each included source's JWS must verify against its JWKS (CBA key for cba, pod key for cdr) and sign that commitment; an excluded source must be the zero commitment with no signature; at least one source signed; both signatures name the same sub; freshness = the oldest iat. The browser re-checks the Groth16 proof with snarkjs. v1 bundles (pod-only) still verify as before.
    • Credential binding. The claim source puts income_commitment_cba (from core banking's /internal/income) and income_commitment_cdr (the pod's) in the CommBank ID; a verifier binds a proof to the credential by matching commitments.cba / .cdr.
  7. Income detection tightened (packages/pod-programs income aggregator, shared by the pod and core banking), because sources are now summed: (a) credits to card and loan accounts (productCategory not a transaction/savings type) are repayments, not income (incomeTransactions, canReceiveIncome); (b) a regular stream with unstable amounts (stability < 0.5) and no pay wording is a top-up from the customer's own money (savings transfers, household kitty), not income. Before this, Alex's NAB savings transfers ($6,855) would have been added to his Westpac salary.