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)- Every event carries
customerSub. Step events carrystepanddetail.status(running/done/failed). Thestatus.steps[].namevalues are the same human-readable strings, so the app can show them as they are. - Nothing sensitive goes on
pod.events: no tokens, amounts or raw data.IncomeReady.detailhas onlyfrequency,confidence,hasCommitmentandretainUntil. - Delivery: the consumer acks only after the arrangement is in the pod's manifest. A failed handler is retried with exponential back-off (XPENDING/XCLAIM, base 2s). Invalid JSON, schema failures and messages that fail 5 times are copied to
cdr.arrangements.dlqwith a reason. Handlers are idempotent: duplicateArrangementEstablished, or a revoke for an arrangement that has already ended, is a no-op. - Loading runs after the ack, in the pod's mailbox. It retries 3 times with back-off. A 404/410 from the token manager, a CDR
InvalidArrangementor any other non-retryable CDS error fails at once. On boot, the runtime resumes any arrangement left increatedorloading.
Runtime
- Actors. One
PodActorper sub. Its mailbox is a promise chain, so pod work is serialised.GET statusreads the in-memory manifest without queueing, while everything else goes through the mailbox. After each task, a dirty store is sealed and written. - Store. A sql.js (WASM SQLite) database lives in memory while the actor is loaded. It is persisted as
pods/{sub}.db.enc, sealed with AES-256-GCM under a per-pod 256-bit data key, with AADpod-db:{sub}. The data key is wrapped withPOD_MASTER_KEY(AES-256-GCM, AADpod-key:{sub}) and stored inpods/{sub}.key. sql.js was chosen over better-sqlite3 so that pnpm 10 and Docker need no native build. - Tables.
- Compartments:
accounts,balances,transactions,collection_marksandsecrets(the commitment salt), all keyed byarrangement_id. outputs: tagged with the use consent they rely on.audit: the CDR record-keeping trail.manifest: arrangements with scopes,consentType,sharingExpiresAt,useConsents,deletionElection, status and phase, plus the installed programs.
- Compartments:
- Storage drivers.
local(POD_LOCAL_DIR, default./.pods) ands3(any S3-compatible bucket, including Railway buckets). SetPOD_STORAGEto choose; it defaults tos3when a bucket name is set. - Signing key. ES256. It comes from
POD_SIGNING_JWK, or is generated once and stored sealed atruntime/signing-key.json.enc. Thekidis the JWK thumbprint, and the key is published at/.well-known/jwks.json.
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:
- regularity, 0.30;
- coverage of 12 months, 0.25;
- amount stability (1 – CV/25%), 0.15;
- completeness (observed versus expected pays), 0.15;
- recency, 0.10;
- a salary keyword, 0.05.
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:
- Purpose binding.
outputs/income?purpose=must becredential-issuance,offer-eligibilityorconsumer-view. Anything else returns 403purpose_not_permitted, which is audited. If the purpose is missing, it defaults tocredential-issuancefor services andconsumer-viewfor the app. - Disclosure records. Each disclosure is audited with the recipient (
cba-internal,mattr-claim-sourceorconsumer) and the purpose. - Proof errors.
POST proofs/incomereturns 403use_consent_missing, 409not_readyor 422threshold_not_met.verifierUrlis{ZK_VERIFIER_URL}/?bundle=<base64url(bundle JSON)>. DELETE …/arrangements/{id}returns a receipt:{ status: "deleted", podDestroyed, adrRevoke: revoked|unknown_at_adr|adr_unreachable|error_N, deletionReceipt? }. It deletes locally first, then calls the adr-cba revoke endpoint on a best-effort basis.- Status. Arrangements also carry
retainUntil(once-off),endReason/endedAtandfailure. A destroyed pod returnsstate: destroyedwith adeletionblock. - SSE. It sends a
statussnapshot first, then pod events for that sub only, with event name = type. It pings every 15s.
Lifecycle and retention
The scheduler runs every POD_SWEEP_INTERVAL_SECONDS (15).
- Sharing expired (
sharingExpiresAthas passed), or anArrangementExpiredevent →endReason=expired. - Once-off consents.
retainUntil= IncomeReady +POD_ONCE_OFF_RETENTION_SECONDS(600). During that window the customer can issue the credential and generate proofs. When it ends →endReason=purpose-fulfilled. - Revoked by an
ArrangementRevokedevent, the consumer DELETE, or a 410 from the token manager during a refresh →endReason=revoked. - Ending an arrangement deletes its compartment rows and derived outputs, then VACUUMs so freed pages don't linger in the image. It writes an audit
deletionwith row counts and emitsArrangementDataDeleted. Adeidentifyelection is honoured by deletion, and the audit entry records that. - No active arrangements left → destroy. The runtime writes
pods/_deleted/{sub}.json, then deletespods/{sub}.db.encandpods/{sub}.key, zeroes the in-memory key and emitsPodDestroyed. The deletion record holds the arrangement metadata and the full audit trail, which contains no CDR data or derived values (the e2e script checks this). Deleting the wrapped key is the shred: any copy of the ciphertext, such as a bucket version or backup, can no longer be decrypted.
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).
- Circuit.
income_gte.circomuses privateincomeandsalt, and public[threshold, commitment]. Both amounts are range-checked to 32 bits withNum2Bits,commitment === Poseidon(income, salt)andGreaterEqThan(32). It has 620 constraints. - Setup.
build.shcompiles withcircom2(WASM, no native circom needed), then runs powers of tau 2^10 and the Groth16 phase 2 using beacon-only contributions with a fixed public beacon. This is demo-only: anyone can derive the toxic waste and forge proofs. In exchange the build is byte-for-byte reproducible, so Docker builds regenerate exactly the committedbuild-artifacts/verification_key.json, andbuild.shfails if they don't. The zkey and wasm are gitignored. - Bundle. It contains
{ proof, publicSignals: [threshold, C], commitment: 0x…, threshold, signature, kid }. The signature is an ES256 JWS (typ: pod-commitment+jwt) over{ commitment, sub, iat }. - Verifier checks:
- the bundle is well formed;
- the Groth16 proof verifies against the committed key;
publicSignalsmatch the stated threshold and commitment;- the JWS verifies against
{POD_RUNTIME_URL}/.well-known/jwks.json, itskidmatches and it signs the same commitment; iatis withinPROOF_MAX_AGE_SECONDS. The page also runs the Groth16 check again in the browser with snarkjs. It shows "Income ≥ $X per year: verified" and never shows the amount.
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 ongoingDocker: 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
- DPoP. If the token manager returns
token_type: DPoP, the adapter sendsAuthorization: DPoP …but no proof, because the pod doesn't hold the ADR's DPoP key. Either adr-cba returns Bearer-bound tokens to the pod, or it proxies the call. - Commitments move with income. A refresh that changes income, or a bank added or stopped, gives a new CDR commitment, so a credential issued earlier no longer matches new proofs. Re-issue the credential.
- One replica. Actors live in process. Running more than one replica would need a per-sub lease, for example a Redis lock or consistent hashing of subs across consumers.
- Pod list in memory. All pods are loaded at boot. That's fine for demo volumes.