Technical design — MOD-073 Document vault¶
Module: MOD-073 — Document vault
System: SD08 — Customer App & Back Office Platform
Repo: bank-app
Module type: BFF Lambda + IaC + UI
FR scope: FR-361, FR-362, FR-363, FR-364
NFR scope: NFR-013, NFR-019, NFR-024
Policies satisfied: PRI-001 (GATE), PRI-003 (AUTO)
Author: AI agent (Claude Opus 4.7)
Date: 2026-05-17
Dependencies (all Deployed): MOD-044, MOD-045, MOD-049, MOD-052, MOD-103, MOD-104
Objective¶
The customer's and back-office's authoritative document store: KYC uploads, signed contracts, supporting evidence, statements (generated by MOD-113 — MOD-073 stores them), and any customer-uploaded artefact tied to their relationship.
Per ADR-028:
- File bytes live in S3 (/bank/{env}/s3/documents/name — owned by MOD-104).
- SSE-KMS encryption via the pii key (/bank/{env}/kms/pii/arn).
- Rich queryable metadata in app.document_metadata on the consolidated
bank DB (ADR-064).
- Access is exclusively via pre-signed URLs (FR-363 — 5-min default
TTL via DOCUMENT_URL_TTL_SECONDS). No document has a permanent public URL.
- Every upload + access event lands in an immutable
app.document_audit_log row (FR-364, ADR-048 Cat 1, NFR-024 = 0).
- Retention is enforced by a scheduled sweeper (PRI-003 AUTO) — documents
past retention_delete_at get deleted from S3 and soft-deleted in
metadata.
NFR-009 (zero divergence between CUSTOMER + BACK_OFFICE modes) is
enforced by single-source ui/ components.
Architecture¶
Customer app / Back-office console
│
├─ POST /documents/uploads → initiateUpload → pre-signed PUT URL (5-min TTL)
├─ POST /documents/uploads/{id}/finalize → finalizeUpload → HeadObject S3 + emit document_uploaded
├─ GET /documents/{id}/download → getDownloadUrl → pre-signed GET URL (5-min TTL)
├─ GET /documents → listDocuments → customer's own
├─ DELETE /documents/{id} → deleteDocument → soft-delete only; sweeper does S3
│
└─ GET /internal/documents/{party_id} → listDocumentsByParty (IAM-authed for back-office)
EventBridge — bank-app bus
├─► bank.app.document_uploaded consumed by bank-kyc (IDENTITY → eIDV)
└─► bank.app.document_retention_purged consumed by SD06 compliance archive
Scheduled (hourly cron)
└─ retention-sweeper PRI-003 AUTO
Customer never streams bytes through Lambda (ADR-028). The client PUTs directly to S3 using the pre-signed URL; the handler only handles metadata + audit.
Data plane¶
Tables added to app schema¶
| V# | Table | Cat | Notes |
|---|---|---|---|
| V001 | app.document_metadata |
mutable + soft-delete | CHECK on document_category enum (note 2); CHECK on file_size_bytes > 0 AND <= 26214400 (25 MB per FR-361); CHECK on checksum format. upload_status PENDING → COMPLETED → FAILED state machine. |
| V002 | app.document_audit_log |
ADR-048 Cat 1 immutable | event_type enum (UPLOAD_INITIATED, UPLOAD_COMPLETED, UPLOAD_FAILED, DOWNLOAD, DENIED, DELETED, RETENTION_PURGED). CHECK actor_type='STAFF' ⇒ length(trim(actor_justification)) > 0 (note 1). CHECK actor_type='SYSTEM' OR actor_user_id IS NOT NULL. trg_document_audit_log_immutable rejects UPDATE/DELETE/TRUNCATE. |
| V003 | access.role_permissions seed |
additive INSERT | m8 RBAC matrix. ON CONFLICT DO NOTHING — idempotent (note 3). |
Cross-domain reads¶
| Cross-schema read | Purpose |
|---|---|
app.consents — consent_type='PRIVACY_POLICY' AND status='GRANTED' |
PRI-001 upload gate (m3) |
access.user_roles + access.role_permissions (entity='app.document_metadata') |
FR-362 back-office category filter |
access.user_identities (FK target on audit) |
actor_user_id validation |
app.customer_sessions (FK target on audit) |
session_id linkage |
All on the same shared pool — ADR-064 schema-per-domain isolation; no cross-DB calls.
SSM outputs (consumer contract)¶
Path convention: /bank/{env}/mod073/{name}.
| SSM path | Value | Consumer |
|---|---|---|
/bank/{env}/mod073/document-api/url |
HTTP API base URL | MOD-069 app shell, MOD-113 statement engine |
/bank/{env}/mod073/document-api/id |
API ID | Diagnostics |
/bank/{env}/mod073/initiate-upload/fn-arn |
Lambda ARN | MOD-113, MOD-053 |
/bank/{env}/mod073/finalize-upload/fn-arn |
Lambda ARN | MOD-113, S3 event handler |
/bank/{env}/mod073/get-download-url/fn-arn |
Lambda ARN | All SD08 modules needing read |
/bank/{env}/mod073/list-documents/fn-arn |
Lambda ARN | MOD-069 |
/bank/{env}/mod073/internal-list-documents/fn-arn |
Lambda ARN | MOD-074 back-office 360 |
/bank/{env}/mod073/delete-document/fn-arn |
Lambda ARN | Diagnostics |
/bank/{env}/mod073/retention-sweeper/fn-arn |
Lambda ARN | Diagnostics |
/bank/{env}/mod073/vault-events-log/group-{arn,name} |
CW log group | MOD-076 |
Upstream SSM paths consumed¶
| Path | Owner |
|---|---|
/bank/{env}/iam/lambda/bank-app/arn |
MOD-104 |
/bank/{env}/observability/adot-layer-arn |
MOD-076 |
/bank/{env}/eventbridge/bank-app/arn |
MOD-104 |
/bank/{env}/kms/operational/arn |
MOD-104 (CW log group) |
/bank/{env}/s3/documents/name |
MOD-104 |
/bank/{env}/kms/pii/arn |
MOD-104 (documents SSE-KMS — m2 ratification) |
Secret: bank-neon/{env}/app_app_user (DATABASE_URL via Pulumi at deploy).
EventBridge events¶
Published on bank-app bus¶
| Event | Notes |
|---|---|
bank.app.document_uploaded |
Emitted at finalize-upload. Consumers: bank-kyc (IDENTITY category drives eIDV scan), MOD-053 (case attachments). Cross-bus IAM grant filed via MOD-104-document-uploaded-cross-bus-grant.handoff.md. |
bank.app.document_retention_purged |
Emitted by the hourly sweeper when an expired document is purged. PRI-003 AUTO evidence. |
Handler contracts¶
POST /documents/uploads (FR-361)¶
Body: { document_category, document_type, file_name, mime_type, file_size_bytes, checksum_sha256, retention_delete_at? }.
PRI-001 gate: customer must have PRIVACY_POLICY consent granted (m3).
Returns 201 with { document_id, s3_key, upload_url, expires_in_seconds }.
| HTTP | error_code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
claims missing |
| 403 | CONSENT_MISSING |
PRIVACY_POLICY consent not granted |
| 413 | FILE_TOO_LARGE |
> 25 MB |
| 415 | UNSUPPORTED_MIME_TYPE |
mime not in allowlist |
| 422 | INVALID_FIELD / MISSING_FIELD |
body validation |
POST /documents/uploads/{id}/finalize¶
Asserts party_id ownership (PRI-001), HeadObject confirms S3 bytes
landed, flips upload_status=COMPLETED, writes UPLOAD_COMPLETED
audit row, emits bank.app.document_uploaded.
GET /documents/{id}/download (FR-363)¶
PRI-001 owner check. Returns 5-min pre-signed GET URL with the SHA-256
checksum so the customer/client can verify integrity. Writes a
DOWNLOAD audit row.
DELETE /documents/{id}¶
Soft-delete only (sets deleted_at). The hourly sweeper deletes the
S3 object on next pass. Writes a DELETED audit row.
GET /internal/documents/{party_id} (FR-362)¶
IAM-authed. Header X-Staff-User-Id identifies the staff actor.
Resolves the active role via access.user_roles, then the permitted
document_category list via access.role_permissions. Returns
documents filtered to that category set. Empty grants → 403 DENIED +
audit row.
Policy satisfaction¶
| Policy | Mode | Mechanism | Tests |
|---|---|---|---|
| PRI-001 | GATE | Owner check on every read (doc.party_id === session.party_id). PRIVACY_POLICY consent gate on upload. RBAC category filter for back-office. DENIED branches write audit + return 403. |
tests/policy/pri-001-gate-static.test.ts — source scan + behavioural. |
| PRI-003 | AUTO | Hourly EventBridge cron → retention-sweeper Lambda → S3 delete + soft-delete + RETENTION_PURGED audit + event emission. Source-level scan rejects bypass tokens. |
tests/policy/pri-003-auto-static.test.ts — source scan + behavioural. |
NFR compliance¶
| NFR | Threshold | How met |
|---|---|---|
| NFR-013 | p99 ≤ 5 ms Postgres reads | metadata queries are single-row + indexed (party_id, document_category, retention_delete_at). |
| NFR-019 | Tier-1 RTO ≤ 4h / RPO ≤ 1h | S3 11×9s durability; metadata via Neon branch-per-env (already meets RPO). |
| NFR-024 | = 0 audit log mutations | trg_document_audit_log_immutable rejects all UPDATE/DELETE/TRUNCATE. |
Observability¶
Per ADR-031. Mandatory fields: trace_id, correlation_id,
module_id=MOD-073, jurisdiction, event_type, party_id, level,
timestamp. CW log group /aws/bank-app/vault-events-{env} is a
secondary SIEM feed; Postgres app.document_audit_log is the durable
record.
Cross-module handoffs filed¶
docs/handoffs/MOD-073-complete.handoff.md— wiki amendments (addapp.document_audit_logto SD08-app.md; register two events in event-catalogue; recast CAP-118howon MOD-073; add MOD-049 + MOD-052 to MOD-073.yaml formal deps; correct CLAUDE.md kms paths).docs/handoffs/MOD-104-document-uploaded-cross-bus-grant.handoff.md— requestevents:PutRule/events:PutTargetson bank-app bus for bank-kyc role, scoped todetail-type = "document_uploaded".
Deployment¶
GitLab CI .gitlab/ci/mod-073.gitlab-ci.yml extends .lambda-mr and
.lambda-deploy from bank-platform. HAS_POSTGRES: "true" runs
flyway validate (pre) and flyway migrate (post) against the bank
DB.
Contract package @bank-app/mod-073-contracts@1.0.0 is published by
the appended mod-073-publish-contracts job whenever contract/**
changes (ADR-063).
Open items¶
- AV scan integration (m5) — magic-byte + MIME + 25 MB cap covers the v1 security bar. ClamAV-on-S3-event integration is deferred to a follow-up handoff; bank-platform owns the scanner infrastructure choice.
- PDF rendering (m1) — MOD-073 doesn't generate statement PDFs.
MOD-113 (statement generation, future Tier C) calls
POST /documents/uploads/{id}/finalizeto land its rendered output. CAP-118'showclause is being re-cast in the wiki via the complete handoff.