28 KiB
ADR 0003 — Object Storage for Document Bytes, With the Database as the Only Index
| Status | Accepted — 2026-07-29 |
| Scope | Where CV and attachment bytes live; container layout and key naming; the upload → quarantine → scan → promote path; how access is authorised; encryption; lifecycle; how erasure and the immutable audit archive coexist |
| Owner | Talha Ahmed (senior). The files module facade and the malware-scan gate are Talha's; the intake triage UI that surfaces failed/quarantined states is Ahmed's, with a Talha review checkpoint |
| Consistent with | _decisions.md Part 1 → Deployment topology ("object storage for CV blobs"), Logical module list — platform tier (module 3, files); Part 2 → Raw intake… (raw_intake_attachment.object_store_key, virus_scan_status), Candidate first-class columns… (candidate_document.sha256, object_store_key), Soft delete, PII classification and retention, Audit table strategy |
| Related ADRs | 0002 (the database deliberately does not hold bytes), 0004 (scanning, parsing and retention sweeps are queue jobs) |
Context
There is no file handling in the repository today, of any kind. The prototype's "CV Import" screen (js/import.js, 176 lines) is a UI simulation over locally generated data; there are zero network calls anywhere in js/ or index.html (_repo-findings.md §C), no storage client, no .env, and no infrastructure definitions (§B). This is a greenfield decision.
It is also the decision with the highest security stakes in the package, because of two facts that hold simultaneously:
- The two primary Phase 1 intake sources are CV files and inbound Outlook mail — both attacker-supplied by design (
_repo-findings.md§E). We are, as a product requirement, running parsers over untrusted binaries uploaded by strangers. - The existing rendering layer has no HTML escaping anywhere — 34
innerHTMLassignments across 14 files, candidate-controlled values interpolated raw (js/candidates.js:68), inline handlers with interpolated ids (js/candidates.js:121). §E is rated P0 for exactly this reason.
So the storage design has to assume every byte is hostile until proven otherwise, and it must not create a second XSS surface by serving candidate-supplied content from an origin that shares a security context with the application.
Forces
| Force | Evidence / figure | Implication |
|---|---|---|
| Volume is small | 20k–60k applications/year, 200–600 documents/day at peak (assumptions); average CV ~400 KB → roughly 10–25 GB/year of originals, well under 100 GB in year one including derivatives and raw mail payloads | Cost and scale are not the deciding factors; correctness, security and erasure are |
| Bytes must land before they can be scanned | raw_intake_attachment.virus_scan_status must be clean before an intake_parse_attempt may be created — enforced in the service and by a trigger |
A pre-scan holding area is structurally required, not optional |
| Erasure must actually delete bytes | Retention purge is pseudonymisation of rows but deletion of CV blobs; retention_action records blob_keys_deleted |
Any storage feature that silently retains deleted bytes (versioning, long soft-delete windows) is in direct conflict |
| History must survive erasure | ats_result, *_history, *_version, raw_intake, candidate_merge are append-only and never deleted |
Metadata rows outlive the bytes they point at — a dangling-key state that must be explicit, not an error |
| The audit archive needs the opposite guarantee | Closed monthly audit partitions are exported daily to write-once storage (Object Lock / immutable blob) with the partition's final row_hash |
Two containers with contradictory retention policies must coexist |
| Access must be derived, never public | files module: "access derived from the owning domain object, never public URLs" |
No permanent URLs, no anonymous containers, no guessable keys |
| Two developers, no ops staff | _repo-findings.md §I |
A managed service with lifecycle rules beats anything self-operated |
Options considered
Option A — Bytes in PostgreSQL (bytea or large objects)
Pros — real, and often underrated
- One store, one transaction, one backup. A CV insert and its metadata commit atomically. There is no dual-write, no orphaned blob, no orphaned row — which is genuinely attractive given how much of this design is about the raw-intake-before-candidate invariant.
- Erasure is a
DELETE/UPDATEin the same transaction as pseudonymisation. No second system to reconcile. - PITR covers documents too, so recovery is one story.
- Access control is the database's, which is already the single authorization chokepoint.
- At our volume (tens of GB/year) it is not even absurd on size grounds.
Cons
- Every byte goes through WAL, is captured in every base backup, and inflates backup/restore time and cost for data that never changes after write. A 25 GB/year blob load makes the database an order of magnitude larger than its relational content, and the database is the one component we cannot scale horizontally (ADR 0002).
- Streaming large objects through the web process ties up a request thread and a database connection for the duration — and connections are the scarce resource in this design (ADR 0002 R2, ADR 0004).
byteareads are all-or-nothing without large-object APIs; range requests and resumable downloads are awkward.- No lifecycle tiering, no server-side immutability/Object Lock, so the audit archive requirement cannot be met by the same mechanism.
- Blob churn creates bloat and vacuum pressure on the same instance serving recruiter queries.
Verdict: the transactional-consistency argument is the strongest case against object storage, and it is a genuine loss. It is not enough: it would put a permanent, growing, write-once load on the single most scaling-constrained component we have, and it cannot satisfy the write-once audit archive.
Option B — Filesystem volume on the application host (or an NFS/SMB share)
Pros
- Simplest possible programming model —
open()andwrite(), nothing to learn, trivial local development. - No per-request signing, no SDK, no additional credentials.
- Very cheap.
Cons
- Breaks the deployment model directly: two revisions from one image on a managed container platform (ADR 0001) means ephemeral, replaceable instances. Persistent state on the app host makes revisions non-fungible and rolling deploys unsafe.
- An NFS/SMB share re-introduces a stateful component that two developers with no ops staff would have to back up, monitor, capacity-plan and restore — the exact cost we are avoiding.
- No server-side immutability, no lifecycle rules, no per-object access tokens, no independent encryption boundary.
- Untrusted files sitting on the same filesystem as application code is a materially worse security posture than a separate account with no execute semantics.
Option C — Managed object storage (Azure Blob Storage), database holds metadata and keys only
Pros
- Purpose-built for write-once, read-occasionally binaries; effectively unbounded, and cheap at our volume.
- Server-side features map one-to-one onto requirements we actually have: private containers, short-lived scoped signed URLs, lifecycle tiering, and immutability/Object Lock for the audit archive — which no other option provides.
- A separate credential and blast-radius boundary from the database. A SQL injection does not hand over CV bytes; a leaked storage key does not hand over the relational model.
- Downloads bypass the web process entirely, so a recruiter pulling a 20 MB PDF does not consume a request thread or a database connection.
- Serving candidate-supplied bytes from a different origin than the application is a real XSS mitigation, directly relevant to §E.
- Aligns with the Azure/M365 assumption already made in ADR 0001, so it lands in the same tenant as Entra ID and Graph.
Cons (stated)
- Loses transactional consistency between bytes and rows. Two failure modes are now possible and must be designed for explicitly: an orphaned blob (bytes written, transaction rolled back) and a dangling key (row committed, bytes missing).
- A second system to configure, secure, monitor and include in disaster-recovery drills.
- Signed-URL expiry, clock skew and CORS become real, debuggable-at-3pm problems.
- Local development needs an emulator or a real dev container, adding setup friction the current repo (with no build step at all, §B) does not have.
- Blob-level versioning and soft-delete — the features you would normally enable for safety — actively undermine the erasure guarantee. This has to be resolved deliberately rather than accepting defaults.
Option D — SharePoint / OneDrive via Microsoft Graph
Pros — a serious option given the M365 assumption, not a strawman
- No new infrastructure at all: the tenant, the licences and the Graph app registration already exist for mail intake (BRD §8.1).
- Enterprise retention labels, eDiscovery, DLP and audit come for free and are already understood by whoever handles compliance internally.
- Recruiters could see documents in a familiar tool without any UI work.
- Microsoft's own antivirus scanning applies on upload.
Cons
- Graph throttling and per-site item limits are designed for human collaboration, not for a service writing hundreds of items/day with retries; throttling responses become an intake reliability problem in the one flow that must never lose a document (BRD §6.3).
- Programmatic per-item ACLs are awkward, and the natural failure mode is a document library that is broadly readable inside the tenant — unacceptable for
sensitive_personalCV content, and a silent bypass ofiam.can(). - "Recruiters can browse the library directly" is a liability, not a feature: it routes around the audit access events the design requires (
audit_eventon profile view/export). - Retention becomes governed by tenant policy rather than by
retention_policyrows, splitting the retention mechanism across two systems — and the design deliberately keeps deletion per-record. - No usable immutability primitive for the audit hash-chain export.
Verdict: rejected for the primary document store, but note that its compliance tooling is genuinely better than ours will be. If legal later requires tenant-level eDiscovery over CVs, this decision should be reopened rather than defended.
Decision
Managed object storage (Azure Blob Storage — labelled an assumption, consistent with the Azure/M365 alignment in ADR 0001) is the only store for document bytes. PostgreSQL holds metadata and keys only, and the database is the sole authority for what exists. No bytes in Postgres beyond small extracted text and jsonb payloads; no bytes on the application filesystem beyond a per-request temporary file that is deleted in a finally.
1. Containers, with deliberately different policies
| Container | Contents | Public access | Versioning | Soft delete | Lifecycle | Immutability |
|---|---|---|---|---|---|---|
intake-quarantine |
Every inbound byte on arrival, pre-scan | Off | Off | Off | Delete 30 days after promotion or rejection | No |
candidate-documents |
Promoted originals (CV revisions, attachments) | Off | Off — deliberately | 7 days, disclosed | Hot → Cool at 180 days. Never Archive tier | No |
document-derivatives |
Sanitised preview renditions produced by the worker | Off | Off | Off | Delete 90 days after last access; regenerable | No |
audit-archive |
Daily export of closed monthly audit.audit_event partitions |
Off | Off | Off | Retain ≥13 months, then policy-driven | Yes — Object Lock / immutable blob, time-based |
exports |
Subject-access exports, report downloads | Off | Off | Off | Delete after 7 days, no exceptions | No |
Two policy decisions in that table are non-obvious and are the point of this ADR:
candidate-documentshas blob versioning OFF and only a 7-day soft-delete window. The reflex is to enable both. We do not, because retention purge must genuinely delete CV bytes, and versioning would silently retain every prior version of a document we have told a data subject we erased. The 7-day soft-delete window is retained as accident insurance and must be disclosed in the retention policy as a recovery window — not quietly relied on.- Archive tier is never used, despite being the cheapest option for old CVs. Rehydration takes hours, and both the retention purge and a subject-access export have to complete inside statutory timeframes. Cool tier is the floor.
2. Key naming
intake-quarantine/{yyyy}/{mm}/{raw_intake_public_id}/{seq}-{sha256_prefix12}.{ext}
candidate-documents/{candidate_public_id}/{candidate_document_public_id}-{sha256_prefix12}.{ext}
document-derivatives/{candidate_document_public_id}/preview-{n}.pdf
audit-archive/{yyyy}/audit_event_{yyyy}_{mm}.jsonl.zst
exports/{requesting_user_public_id}/{export_public_id}.zip
Rules:
- Keys are built from
public_id(UUIDv7) only — never frombigintPKs, never fromreference_code(which is enumerable by construction and internal-only, per ADR 0002), and never from a candidate's name or email. - The
sha256prefix in the key is for integrity and human debuggability, not addressing. Bytes are not globally deduplicated across subjects even when two candidates submit a byte-identical CV. This is a conscious trade: global content-addressed dedupe would save trivial storage at our volume while making per-subject erasure a reference-counting problem, where deleting one candidate's document could either delete another's or silently retain the erased one. Duplicate detection still uses identicalcandidate_document.sha256as a matching signal — that is a row comparison and needs no shared bytes. - The database is the authority. Prefixes are an operational convenience for browsing, not the deletion mechanism: merges re-point documents between candidates without moving bytes, so the retention purge walks
candidate_document/stored_filerows and deletes the keys it finds. A prefix-based delete would miss re-pointed documents.
3. The intake path — bytes are hostile until proven otherwise
graph TD
IN["Inbound: Outlook via Graph · career portal upload · manual recruiter upload"] --> VAL{"Pre-persist validation<br/>magic bytes · 25 MB cap · MIME allowlist"}
VAL -->|reject| REJ["raw_intake_attachment state = rejected<br/>reason recorded · surfaced in intake triage UI"]
VAL -->|accept| Q[["intake-quarantine<br/>bytes land here first"]]
Q --> SCAN["worker: malware scan<br/>queue = ingest"]
SCAN -->|infected| QUAR["virus_scan_status = infected<br/>bytes retained 30d for forensics<br/>never promoted · never parsed"]
SCAN -->|clean| PROMO["server-side copy to candidate-documents<br/>stored_file row committed"]
PROMO --> PARSE["worker: parse under restricted OS user<br/>no outbound network · CPU/wall timeout<br/>queue = parse"]
PARSE --> DERIV["sanitised preview derivative<br/>document-derivatives"]
PROMO --> DELQ["delete quarantine copy"]
Enforced rules on that path:
| # | Rule |
|---|---|
| 1 | Validation precedes persistence. Magic-byte allowlist (PDF, DOCX, DOC, ODT, RTF, TXT); extension and client Content-Type are never trusted; SVG and HTML rejected outright; .docm rejected; 25 MB cap; decompression-ratio cap; nested archives rejected |
| 2 | virus_scan_status = 'clean' is a gate, enforced in the service and by a database trigger — no intake_parse_attempt can exist for an unscanned or infected attachment |
| 3 | Rejection is a visible state, never a silent drop. Every terminal failure surfaces in the intake triage UI (BRD §6.3) |
| 4 | Uploads are server-mediated in Phase 1 — the client posts to /api/v1/..., the web process validates, then streams to quarantine. No direct-to-blob presigned upload, because validation must run before bytes are persisted anywhere durable, and at 200–600 documents/day the web-tier cost is negligible |
| 5 | Orphan reconciliation is a scheduled job, not a hope. A nightly maintenance task lists blobs with no stored_file row older than 24 h and deletes them; and lists stored_file rows whose key is missing and flags them as bytes_missing rather than throwing on read. This is the accepted price of losing transactional consistency (Option C's main cost), and it is paid explicitly |
4. Read access
| Rule | Detail |
|---|---|
| Authorisation first | Every download calls iam.can(actor, 'read', document) deriving permission from the owning domain object, then files.signed_url() mints a URL. There are no permanent URLs and no anonymous containers |
| TTL | 120 seconds, read-only, single blob, IP-agnostic. Long enough for a click, short enough that a leaked URL in a chat log or referrer is worthless |
| Audit | Every issuance writes an audit_event access record (actor, document, purpose). This is why direct SharePoint browsing was rejected — it would bypass this |
| Content headers | Content-Disposition: attachment, an allowlisted Content-Type, X-Content-Type-Options: nosniff. Originals are download-only |
| Inline preview | Serves the sanitised derivative (embedded JavaScript and embedded files stripped during parse), never the original. Candidate-supplied bytes are never rendered inline from the application origin |
| Origin separation | Blobs are served from the storage domain, not the app origin, so a hostile document cannot execute in the application's security context — a structural complement to the §E escaping work |
5. Encryption, credentials, environments
- Encryption at rest: platform-managed keys in Phase 1; customer-managed keys (Key Vault) deferred to Phase 3 and only if legal requires it. TLS in transit, enforced; HTTP disabled on the account.
- Credentials: managed identity from both
webandworker, scoped per container to the minimum role (workerneeds write on quarantine + derivatives and delete on quarantine;webneeds read + the signing right). Shared account keys are disabled. No storage credential in application configuration. - Environments: one storage account per environment, never shared. Staging is seeded with synthetic documents only — no production CV is ever copied to staging, and the non-production anonymisation script reads the
pii_classificationregistry rather than a hand-maintained list. localuses a storage emulator or a dedicated dev account; thefilesfacade is the only code that knows which.
Justification
The requirements pull in two directions that only object storage satisfies simultaneously. CV bytes must be deletable on demand (erasure), while audit exports must be undeletable (tamper evidence). One store with per-container policy handles both; Postgres handles neither well, and a filesystem handles neither at all.
We deliberately accept losing atomicity between bytes and rows, and pay for it in the open. This is the honest weak point of the decision, so it gets a named mechanism rather than a shrug: quarantine-first ordering means bytes always exist before the row that references them is promoted; a nightly reconciliation job cleans orphans; and bytes_missing is a first-class state because append-only history rows will outlive purged blobs by design. Note the asymmetry we chose: an orphaned blob is a cost problem, a dangling key is a UX problem, and neither is a correctness problem — whereas the reverse ordering (row first, bytes later) would let a clean-gated parse attempt reference bytes that never arrived.
Quarantine-before-promotion is not ceremony. The design runs parsers over attacker-supplied binaries as a core product function. Landing bytes in a container the parse path cannot read until a scan clears them makes "no unscanned file is ever parsed" a property of the storage layout plus a database trigger, rather than a code path someone can forget under deadline pressure.
Serving documents from a different origin, download-only, with sanitised previews, is the storage-layer half of the §E fix. Escaping HTML in the rendering layer stops candidate text from executing. It does nothing about a candidate-supplied PDF or HTML file opened inline from the app origin. Both halves are needed, and this ADR owns the second.
Turning off blob versioning is the decision most likely to be questioned, so the reasoning is recorded. Versioning plus a long soft-delete window is the standard safety configuration and we are declining it. The reason is that this platform makes an explicit promise — pseudonymise rows, delete CV bytes — and a storage feature that silently retains prior versions would make that promise false without anyone noticing. We accept a smaller accident-recovery margin (7 disclosed days, plus the fact that raw intake attachments are re-parseable from quarantine within 30 days of arrival) in exchange for an erasure guarantee we can defend.
Consequences
Positive
- Documents never touch the database's WAL, backups or connection pool, so the one component that cannot scale horizontally (ADR 0002) stays small and fast.
- Downloads bypass the web process entirely — no request thread, no DB connection, no memory spike on a 20 MB PDF.
- A separate credential and blast-radius boundary: compromising the database does not yield CV bytes, and vice versa.
- Immutable
audit-archivegives the audit hash chain the one genuinely independent tamper check it has (layers 1–3 are all inside the database). - Retention purge has a real deletion primitive, and
retention_action.blob_keys_deletedmakes each purge provable. - Cost is negligible at projected volume, and Cool tiering handles the long tail without touching code.
Negative — costs we are accepting
| Cost | Accepted because |
|---|---|
| No atomicity between bytes and rows. Orphaned blobs and dangling keys are both possible | Quarantine-first ordering, a nightly reconciliation job, and bytes_missing as an explicit state. Named as the price of Option C |
| A second stateful system to secure, monitor, drill and include in DR | Managed, no capacity planning, and it is the only option that provides immutability |
| Local development friction — an emulator or dev account is now required in a repo that today has no build step at all (§B) | The files facade is the only code aware of it; setup is documented once |
| Signed-URL operational surface: expiry, clock skew, CORS | 120 s TTL is a deliberate trade of convenience for leak resistance; the failure mode is a re-click, not data loss |
| Reduced accident recovery (no versioning, 7-day soft delete) | The erasure guarantee outranks it; raw attachments remain re-parseable from quarantine for 30 days |
| No global content dedupe, so identical CVs are stored twice | Storage is cheap at this volume; reference counting would make erasure unsafe |
| Backup/restore is two systems, not one. A PITR restore of the database to time T does not restore blobs deleted after T | Blobs are write-once and the 7-day soft-delete window covers the realistic restore horizon. Documented in the DR runbook as a known non-atomic recovery |
| Server-mediated upload costs web-tier CPU and memory | Trivial at 200–600 documents/day; validation-before-persistence is worth more than the saving. Direct-to-blob has a numeric revisit trigger below |
Risks
| # | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | A container is misconfigured to allow anonymous or broad read access, exposing CV PII | Low–Medium | Severe | Public access disabled at account level; infrastructure-as-code with a CI check asserting allowBlobPublicAccess = false and no container ACL is public; shared keys disabled so only managed identity works |
| R2 | The malware-scan gate is bypassed by a code path that promotes or parses directly | Low | High | Gate enforced twice — service check and a database trigger on intake_parse_attempt; the parse queue reads only from candidate-documents, which quarantine bytes never enter until clean |
| R3 | A signed URL leaks via referrer, screenshot or chat and is replayed | Medium | Medium | 120 s TTL; read-only, single-blob scope; every issuance audited so replay is at least detectable after the fact |
| R4 | Retention purge deletes rows but silently fails to delete blobs (or vice versa), leaving bytes we have claimed to erase | Medium | High — a compliance failure, not just a bug | Purge is a queue job with per-key result recording in retention_action; a weekly verifier re-checks that every purged stored_file key returns 404 and alerts on any that do not |
| R5 | Orphaned blobs accumulate from failed uploads and rollbacks | High (expected) | Low | Nightly reconciliation job; cost is negligible either way |
| R6 | A recruiter opens a hostile PDF inline and it executes | Low–Medium | High | Previews serve the sanitised derivative only; originals are Content-Disposition: attachment from a separate origin; SVG/HTML rejected at upload |
| R7 | Legal determines CV storage must sit under tenant eDiscovery/retention labels | Medium | Medium | The files facade is the only integration point, so the backing store is swappable; Option D is explicitly recorded as the fallback rather than dismissed |
| R8 | Storage region conflicts with a residency ruling across the six jurisdictions | Medium | Medium–High | Same posture as ADR 0002: one region, per-record retention, escalated to legal. Per-region storage accounts are as forbidden as per-region databases |
| R9 | An immutability (Object Lock) policy is set too aggressively and blocks a legitimate audit redaction | Low | Medium | Audit payloads store hashes rather than values for sensitive_personal columns, so the archive should never need redaction; the narrow audit_event_redaction path applies to the live table only, and the archive is time-locked rather than legal-hold-locked |
Revisit conditions
| # | Trigger | Threshold | Expected response |
|---|---|---|---|
| T1 | Total stored volume | >2 TB across all containers | Review tiering policy and per-document size cap; re-evaluate whether Cool-only is still right |
| T2 | Egress | Sustained storage egress cost >20% of total platform infrastructure spend | Introduce a CDN in front of document-derivatives only (never originals) |
| T3 | Upload cost on the web tier | p95 upload-request duration >10 s, or upload handling >20% of web-process CPU | Move to direct-to-blob upload with a server-issued short-scope write token, keeping validation as a post-upload gate in quarantine — the ordering guarantee is preserved because quarantine is already pre-scan |
| T4 | Signed-URL issuance | >5,000 issuances/hour sustained | Introduce short-lived per-session caching of URLs; re-check that audit access-event volume is still tractable |
| T5 | Scan latency | p95 time from arrival to virus_scan_status = clean >60 s, or >5 min at p99 |
Scale the ingest queue or move scanning to a dedicated worker revision (ADR 0001 T5) |
| T6 | Document size | A legitimate business need for documents >25 MB (e.g. portfolio bundles) | Raise the cap only with a matching increase in parse timeout and memory cap, and re-test the decompression-ratio guard |
| T7 | Compliance | Legal requires tenant-level eDiscovery/DLP over CVs, or customer-managed encryption keys | Reopen this ADR: Option D for eDiscovery, or Key Vault CMK, whichever is actually mandated |
| T8 | Erasure verification | The weekly purge verifier reports any key still readable after a recorded deletion | Treat as a P1 incident; halt further purges until the cause is found, because the failure is silent by nature |
| T9 | Provider | The Azure assumption is falsified (Utopia is not on Azure/M365) | The files facade and container model port unchanged to S3 + Object Lock; only the client and identity mechanism change. Nothing above depends on Azure-specific semantics beyond naming |