CommBank ID + income

Design · docs/pods.md

CDR Data Pods

A pod is a per-customer, self-managing store for CDR data. There is one pod per CBA customerSub. It holds CDR data only (CONTRACTS v1.9): income from the customer's own CommBank accounts is worked out by CBA core banking (participants/cba-issuer/core-banking), which runs this package's income aggregator as a library, and never enters a pod. A customer who uses only their CommBank accounts has no pod. It is created from the cdr.arrangements stream once adr-cba holds tokens for a consent. It loads and refreshes itself within the consent, runs consent-gated programs and destroys itself in line with the CDR rules.

Code:

Path What
packages/events zod schemas + TS types for every stream event and API shape, Redis Streams helpers (publish, consumer group with retry and dead-letter, tail)
packages/pod-runtime Controller, per-pod actors, encrypted store, HTTP API, lifecycle scheduler, dev fakes and the e2e script
packages/pod-programs Token manager, CDS adapter, loader, refresher, income aggregator, ZKP prover, synthetic CDS data
packages/zk-circuits income_gte.circom, build.sh (demo-only setup), JS helpers, circuit tests
apps/zk-verifier Agent-facing verifier page + POST /api/verify

Event flow

cdr.arrangements (group pods)          pod.events
ArrangementEstablished ──► controller ──► PodCreated
                           actor(sub) ──► PodLoading  step=Loading accounts | Loading balances | Loading transactions
                                      ──► PodLoaded
                                      ──► PodLoading  step=Calculating income | Preparing income proof
                                      ──► IncomeReady            (or PodFailed reason=no_recurring_income | load_failed…)
POST /proofs/income                   ──► ProofIssued
ArrangementRevoked / Expired /
DELETE arrangement / once-off window  ──► ArrangementDataDeleted ──► PodDestroyed (when no active arrangement is left)

Runtime

Programs and consent gating

The runtime runs programs only through runProgram. It calls checkEligibility first: the arrangement must be active, the consent type must be allowed, and the program's requiredScopes and requiredUseConsents must all be present. A refusal is written to the audit log (action=refusal, with the code and what was missing) and comes back from the API as 403.

Program Scopes Use consents Notes
token-manager – – POST {ADR_CDR_KIT_URL}/internal/arrangements/{id}/access-token. The token exists only on the stack of one run and is never stored, logged, audited or streamed.
dh-adapter (CdsClient) – – One client for every brand, driven by resourceBaseUri. It appends /cds-au/v1 unless the URI already ends in it. It sends x-v/x-min-v (accounts 2/1, balances 1/1, transactions 1/1), a new x-fapi-interaction-id per call (checking the echo), x-fapi-auth-date, and x-cds-client-headers when the customer is present. It follows links.next, retries 429/5xx with Retry-After/back-off, and maps the CDR error model to CdsError.
loader accounts.basic, transactions – Accounts → bulk balances → transactions for POD_TRANSACTION_HISTORY_DAYS (365). Emits a step per stage.
refresher accounts.basic, transactions – Ongoing consents only. Every POD_REFRESH_INTERVAL_SECONDS it refreshes unattended: balances, plus transactions since the last collection with a 3-day overlap. Each DH call is charged against POD_UNATTENDED_CALL_BUDGET per arrangement per UTC day, and an exhausted budget is audited as a refusal. Income is then derived again.
income-aggregator transactions income-verification See below.
zkp-prover transactions income-proof commit: C = Poseidon(annualIncome, salt). The salt stays in the compartment and is replaced when income changes. prove: a Groth16 proof plus a JWS.

Income detection. The aggregator looks at posted credits only. It excludes fees, interest, refunds, reversals, ATO payments and sweeps from the customer's own accounts. It groups credits by a normalised payer key, with references and boilerplate such as SALARY/PAY/REF stripped out, and merges credits on the same day. From the median interval it classifies a cadence of 7, 14 or about 30.4 days. Amounts more than ±35% from the median (bonuses, back-pay) are left out of the average. Confidence is a weighted score made up of:

Streams scoring under 0.5 are dropped, and so is a stream with unstable amounts (stability under 0.5) and no pay wording - that is someone topping up their own account, not income. Credits to card and loan accounts are repayments and are left out before detection (incomeTransactions). Of the rest, the one with the largest annualised × confidence wins: one stream per data holder.

Several banks (v1.9 §56). The customer can connect more than one bank (one arrangement per DH, sequential consents). The pod's income is the sum across its active arrangements: outputs/income returns annualised = the sum, the largest source's frequency/employer/brand, and sources[]. A bank with no recurring income fails on its own arrangement and doesn't block the others. The pod state stays out of income_ready while any arrangement is still loading, so offers waits for the new bank. annualised is the net pay in whole AUD (average × 52, 26 or 12), so it fits the 32-bit circuit.

HTTP API

The API is as specified in CONTRACTS.md. Auth is INTERNAL_API_KEY, sent as Bearer, x-api-key (for the MATTR claim source) or ?access_token= (for EventSource). The alternative is a CBA JWT checked against CBA_JWKS_URL/CBA_ISSUER, whose sub must match. /claims/mattr accepts the internal key only.

Things the contract doesn't spell out:

Lifecycle and retention

The scheduler runs every POD_SWEEP_INTERVAL_SECONDS (15).

A new ArrangementEstablished after a pod is destroyed creates a fresh pod with a new key.

ZK proof

Two proofs exist. The pod's own POST /pods/{sub}/proofs/income (below) proves the CDR figure alone. Since v1.9 the app uses CBA's total-income proof instead: core banking proves CBA income + CDR income ≥ threshold with income_sum_gte.circom, from two signed commitments. For that, the pod hands over only the opening of its commitment - POST /pods/{sub}/proofs/income-share (internal key, income-proof use consent) returns { annualIncome, salt, commitment, signature, kid }, and each arrangement's audit trail records the disclosure (income-commitment-opening to cba-income-proof). No accounts or transactions leave the pod. The commitment is over the combined CDR income; its salt lives in the anchor arrangement's compartment (the earliest active one with income-proof).

Running it

redis-server --daemonize yes
pnpm install
bash packages/zk-circuits/build.sh                       # ~10s, reproducible

# tests
pnpm --filter @cba/zk-circuits test                      # circuit: valid / threshold above income / tampered
pnpm --filter @cba/events test                           # streams: ack, retry, DLQ (needs redis)
pnpm --filter @cba/pod-programs test                     # aggregator (weekly/fortnightly/monthly + noise), gating, CDS client
pnpm --filter @cba/pod-runtime test                      # crypto, store compartments, storage
pnpm --filter @cba/zk-verifier test

# full lifecycle: fakes + real pod-runtime + real zk-verifier, Redis DB 15
pnpm --filter @cba/pod-runtime dev:e2e

# manual: fakes, runtime, verifier in three terminals
pnpm --filter @cba/pod-runtime dev:fakes
POD_MASTER_KEY=«redacted»
  CBA_JWKS_URL=http://localhost:7300/dev/jwks.json pnpm --filter @cba/pod-runtime start
pnpm --filter @cba/zk-verifier start
pnpm --filter @cba/pod-runtime exec tsx dev/publish.ts establish cba-cust-001 westpac ongoing

Docker: docker compose -f packages/pod-runtime/docker-compose.yml --profile fakes up --build. Each service has its own railway.json, and the build context is the repo root.

Known gaps