Skip to content

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 — jurisdiction is 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