Skip to content

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.consentsconsent_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

  1. docs/handoffs/MOD-073-complete.handoff.md — wiki amendments (add app.document_audit_log to SD08-app.md; register two events in event-catalogue; recast CAP-118 how on MOD-073; add MOD-049 + MOD-052 to MOD-073.yaml formal deps; correct CLAUDE.md kms paths).
  2. docs/handoffs/MOD-104-document-uploaded-cross-bus-grant.handoff.md — request events:PutRule/events:PutTargets on bank-app bus for bank-kyc role, scoped to detail-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}/finalize to land its rendered output. CAP-118's how clause is being re-cast in the wiki via the complete handoff.