Jurisdiction runtime model¶
Decision: ADR-042 — Single-stack deployment with jurisdiction as runtime context
One deployment per environment serves both NZ and AU customers. Jurisdiction is data — an attribute on the party, account, and JWT — not an infrastructure boundary.
Core principle¶
Jurisdiction determines what rules apply, not which deployment handles it. The same Lambda function processes NZ and AU payments. It reads jurisdiction from context and branches accordingly. There are no NZ Lambdas and AU Lambdas — there are Lambdas that are jurisdiction-aware.
Where jurisdiction lives¶
| Layer | Location | Set when |
|---|---|---|
| Identity | access.user_identities (via Cognito custom:jurisdiction) |
Customer onboarding |
| Party | banking.customer_relationships.jurisdiction |
Onboarding — primary jurisdiction |
| Account | accounts.accounts.jurisdiction |
Account opening — per account |
| Request | JWT claim custom:jurisdiction |
Cognito authentication |
| Background job | Read from account or party record | No JWT context |
custom:jurisdiction in the JWT is derived from the customer's Cognito user attribute at token issuance. It reflects the customer's primary jurisdiction. For operations on a specific account, the account's own jurisdiction column is authoritative — a NZ-primary customer may hold an AU account (future capability).
JWT structure¶
Customer token (Cognito — bank-customers-{env} pool)¶
Issued on successful authentication. Valid 1 hour. Refresh token valid 30 days.
| Claim | Type | Description |
|---|---|---|
sub |
uuid | Cognito user identifier — maps to access.user_identities.cognito_sub |
custom:user_id |
uuid | Stable access.user_identities.user_id — the cross-domain user anchor |
custom:party_id |
uuid | party.parties.party_id — the customer's legal identity anchor |
custom:jurisdiction |
string | NZ or AU — customer's primary jurisdiction |
cognito:groups |
array | Cognito group memberships (e.g. customers, business_customers) |
iss |
string | Cognito pool issuer URL |
aud |
string | App client ID |
exp / iat |
epoch | Standard expiry / issued-at |
Not in the JWT: kyc_status, cdd_tier, aml_risk_rating. These change asynchronously and must be read from the source tables at GATE enforcement points — stale JWT claims must not bypass a compliance gate.
Staff token (Cognito — bank-staff-{env} pool)¶
| Claim | Type | Description |
|---|---|---|
sub |
uuid | Cognito identifier |
custom:staff_id |
string | Internal staff identifier |
cognito:groups |
array | Role groups: compliance_officer, customer_service, risk_analyst, engineer, back_office, executive |
iss / aud / exp / iat |
— | Standard |
Staff tokens do not carry jurisdiction — staff operate across both jurisdictions per their role permissions.
Service-to-service context (no JWT)¶
Internal Lambda-to-Lambda calls within a domain use IAM role trust — no JWT. The calling Lambda passes a request context envelope instead:
{
"request_id": "uuid",
"idempotency_key": "string",
"jurisdiction": "NZ",
"source_module": "MOD-020",
"trace_id": "uuid",
"correlation_id": "uuid"
}
jurisdiction here is derived from the originating request's JWT claim or from the account being operated on.
How Lambdas consume jurisdiction¶
User-initiated requests¶
API Gateway validates the JWT using the Cognito authoriser. The validated claims are injected into the Lambda event's requestContext.authorizer.claims. Lambdas read:
jurisdiction = event.requestContext.authorizer.claims["custom:jurisdiction"]
user_id = event.requestContext.authorizer.claims["custom:user_id"]
party_id = event.requestContext.authorizer.claims["custom:party_id"]
Account-level operations¶
For operations on a specific account, the account's own jurisdiction is used, not the JWT claim. This handles future multi-jurisdiction customers correctly:
account = db.query("SELECT jurisdiction FROM accounts WHERE id = ?", account_id)
jurisdiction = account.jurisdiction
Background operations (scheduled tasks, CDC, event consumers)¶
No user JWT. Jurisdiction is read from the record being processed:
- Payment processing: payments.payments has no jurisdiction column — derived from from_account_id → accounts.accounts.jurisdiction
- AML screening: customer's banking.customer_relationships.jurisdiction
- Regulatory reporting: query filtered by account jurisdiction
What jurisdiction changes¶
| Concern | NZ | AU |
|---|---|---|
| AML/CFT legislation | AML/CFT Act 2009 — RBNZ / FMA supervision | AML/CTF Act 2006 — AUSTRAC supervision |
| Suspicious transaction report | STR to FMA | SMR to AUSTRAC |
| IFTI threshold | NZD 1,000 | AUD 10,000 |
| Depositor protection | DCS (Deposit Takers Act 2023) | Financial Claims Scheme (AU) |
| KYC identity verification | NZ DVS, DIA, illion | AUSTRAC DVCL, Australian Document Verification Service |
| Tax reporting | CRS to Inland Revenue NZ; no FATCA withholding obligation | CRS to ATO; FATCA possible |
| Retirement savings | KiwiSaver (voluntary member contributions + employer + government MTC) | Superannuation (Superannuation Guarantee — employer mandatory) |
| Payment rails | NPP (AU) not applicable; SWIFT; direct credit | NPP / PayID (AU); BPAY; SWIFT |
| Interest withholding tax | RWT (Resident Withholding Tax) | Interest income — ATO |
| Overdraft / credit | CCCFA (Credit Contracts and Consumer Finance Act) | National Consumer Credit Protection Act |
What jurisdiction does NOT change¶
- Infrastructure topology (same stack, same Neon project branch, same Snowflake account)
- Authentication flow (same Cognito pool, same auth steps)
- Core data model (same tables, jurisdiction is a column)
- Event schemas (same EventBridge events —
jurisdictionis a field in the event payload) - Module architecture (same Lambda functions, same repos)
Jurisdiction-specific behaviour pattern¶
Modules implement jurisdiction-specific logic via a configuration-driven strategy pattern. The preferred pattern is a jurisdiction config object loaded at cold start:
jurisdictionConfig = loadConfig(jurisdiction) // reads from SSM or environment
rules = jurisdictionConfig.amlRules
threshold = jurisdictionConfig.iftiThreshold
reportType = jurisdictionConfig.suspiciousActivityReportType
Hardcoded if jurisdiction == 'NZ' blocks are acceptable for simple two-way branches but should be refactored to config objects when the logic exceeds ~3 lines. This keeps the Lambda testable with either jurisdiction in isolation.
Future: multi-jurisdiction customers¶
A customer who holds accounts in both NZ and AU has:
- One party.parties record
- One access.user_identities record
- One custom:jurisdiction claim = their primary jurisdiction (where they onboarded)
- Two banking.customer_relationships records (one per jurisdiction)
- Accounts each carrying their own jurisdiction column
The app presents a unified view (single login, both account sets visible). Regulatory obligations are applied per account, not per user. The party model already supports this — no schema changes required.
Cognito pool configuration¶
One customer pool per environment. See MOD-104 for provisioning detail.
| Pool | Users | MFA | Custom attributes |
|---|---|---|---|
bank-customers-{env} |
All retail customers — NZ and AU | Required (biometric + TOTP fallback) | custom:user_id, custom:party_id, custom:jurisdiction |
bank-staff-{env} |
Internal employees + contractors | Required (TOTP) | custom:staff_id |