Skip to content

Object-Storage Layout

How every file in the bucket is named, who decides the name, and how a file gets from an upload to its final key.

Infrastructure (which bucket, which provider, credentials, backups) is a separate concern — see docs/runbooks/object-storage.md. Rolling an existing environment onto this layout is docs/runbooks/storage-layout-backfill.md.

Why it exists

Before this layout the repository had ~27 inline key templates, seven disagreeing copies of a filename sanitiser, and the bucket name re-derived in ~20 files each with its own ?? 'breezycorp' fallback. Two implementations of the same payroll export wrote to two different prefixes. Finalize endpoints accepted a client-supplied S3 key verbatim, so a request body containing ../../other-tenant/x.pdf would have been written as-is. Some columns named …FileId actually held raw storage keys, which made a key move silently break the query that used it as a join key.

All of that is now one package (@breezycorp/storage) and one resolver (resolveFileTarget in @breezycorp/domain).

The canonical key

<tenantSlug>/<groupSlug>/<entitySlug>/<product>/<lane>/<period>/<filename>

Always exactly seven segments for an entity-scoped file. A real example:

spade/acme-holdings/acme-pte-ltd/bookkeeping/documents/2026-04/April2026BankStatement-DBS-8821-20260503T091522Z.pdf
SegmentSourceNotes
tenantSlugTenant.slugImmutable. Assigned once from legalName.
groupSlugGroup.slugImmutable, unique per tenant. Distinct from groupCode, which is operator-assigned and upper-case.
entitySlugEntity.slugImmutable, unique per group.
productProductSegmentSpelled out (bookkeeping, not bkp) — humans browse the bucket. shared for artifacts belonging to no product.
lanelaneFor(fileKind)documents | exports | working | packages. Derived, never chosen by a writer.
periodperiodFolder(periodRef)Sortable: 2026-04, 2026-Q1, FY2026, or undated.
filenamebuildFiledKey<PeriodToken><KindToken>[-qualifier]-<stamp>[-n].<ext>

Filename anatomy:

April2026BankStatement - DBS-8821 - 20260503T091522Z - 2 .pdf
└────────┬───────────┘   └───┬───┘   └──────┬──────┘   │
  period token + kind    qualifier        stamp    collision suffix
                     (bank+last4,                  (only when ≥2)
                      vendor, v3)

The displayName stored on the row — and served via Content-Disposition — drops the stamp and the collision suffix: April2026BankStatement-DBS-8821.pdf. Those tokens exist only to make the key unique.

Slugs are immutable

A legal-name change must not move a client's files, so immutability is enforced by database trigger (*_slug_immutable), not by convention. Slugs are assigned by a BEFORE INSERT trigger when not supplied, so "every row has a valid unique slug" holds even for rows created outside the application.

slugifyName() is deterministic: the same name always yields the same slug. A name that folds to nothing (pure CJK, emoji) gets a stable e-<hash> stub rather than an exception — this runs on entity creation and must never block onboarding.

Reserved namespaces

Files that hang off no entity live under a reserved second segment. The leading underscore is what guarantees they can never collide with a slug — slugifyName cannot produce a leading _, and assertSafeKey rejects one in any user-derived segment.

PrefixContentsExample
_inbox/<yyyy>/<mm>/<dd>/Landing zone. Carries no name and no PII — an inbound attachment's filename is client-controlled and often reveals the sender. Date fan-out keeps any one prefix listable.spade/_inbox/2026/07/23/clx9a8b7c6d5e4f3.pdf
_unfiled/Classified but not routable to an entity — needs an operator.spade/_unfiled/clx9a8b7.pdf
_staff/<staffUserId>/<area>/Staff → platform support attachments; no client entity involved.spade/_staff/stf_01hx/support/SupportAttachment-20260503T091522Z.png
_admin/<area>/Tenant-wide output. area is audit-exports or templates.spade/_admin/audit-exports/…csv
_runs/<runId>/<product>/Agent-run artifacts — only when AgentRun.entityId is null.spade/_runs/run_7f2a/immigration/…
<groupSlug>/_group/…Group-scoped, entity-less (e.g. a ticket raised against a group).spade/acme/_group/tickets/documents/undated/x.png
_quarantine/, _tmp/Reserved, not yet used.

A key prefix is a naming convention, not a security boundary. Tenant isolation is enforced by Postgres RLS. parseKey() exists for diagnostics and the backfill only — never use it for authorization, or a rename becomes a privilege escalation.

Who owns the key

The server. Always. The key never appears in a request or a response.

1. POST …/uploads      → reserveUpload()   → { fileId, uploadUrl }   ← no key
2. PUT <uploadUrl>                                                    ← browser streams bytes
3. POST …/uploads/:id/complete → completeUpload({ fileId, sha256 })   ← server resolves key by id

Looking the row up by id under the tenant transaction is what makes step 3 safe: a caller cannot name a key, cannot reach another tenant's row (RLS returns nothing), and cannot finalize twice into two rows.

Because landing keys embed the file id, two presigns of the same document now produce two distinct keys — so the key-identity idempotency that @@unique([entityId, storageKey]) used to give is gone. Content hash replaces it: completeUpload looks for an existing row with the same sha256 in the same entity and, on a hit, deletes the duplicate object and row and returns the original. That is what makes a double-clicked upload a no-op.

Multipart routes (bytes arrive with the request, server does the PUT itself) use reserveFileRow / reservePayrollFileRow and skip the presign.

Generated artifacts skip all of this

writeGeneratedArtifact() writes a system-produced deliverable straight to its canonical key. Entity, kind, period and version are all known at write time, so there is nothing for a classifier to decide and nothing to move afterwards. The row is created before the bytes, because the key's timestamp is the row's createdAt.

Filing lifecycle

                                     ┌──────── nothing routable yet ────────┐
                                     │      (stays in _inbox, operator      │
                                     ▼               queue)                 │
 presign      PUT        finalize            classify          filer worker │
    │          │            │                    │                  │       │
    ▼          ▼            ▼                    ▼                  ▼       │
 PENDING ──▶ bytes in ──▶ LANDED ─────────────────────────────▶ FILING ──▶ FILED
             _inbox      (sha256                                  │
                          dedupe)                                 └──▶ FAILED

FileFilingState lives on both File and PayrollFile.

StateMeaning
PENDINGRow reserved at presign; no bytes yet. Every list query must exclude these or empty rows leak into document lists and batch counts.
LANDEDBytes confirmed present, awaiting classification/filing.
FILINGA move is in flight; pendingObjectKey holds the target so a crashed filer resumes without a scan.
FILEDAt its canonical key.
FAILEDMove failed irrecoverably; needs an operator.

The filer worker — storage.file-object

Ordering is intent first, delete last:

  1. resolve the target
  2. no-op if it already matches
  3. commit FILING + pendingObjectKey — makes a crash recoverable
  4. copy source → target
  5. commit the new key, FILED
  6. schedule the source delete, past the presign grace window

Every step is safe to repeat. That is a direct consequence of the target key being a pure function of database state: the timestamp comes from the owning row's createdAt, never Date.now(), so a retry recomputes the same key and the copy is an idempotent overwrite rather than a second object.

The deferred delete waits maxPresignTtlSeconds + 60 because a presigned GET is bound to bucket+key — deleting the source any sooner would 404 a link already in someone's hands. That is the entire reason the presign ceiling is a config constant rather than a per-call-site literal.

Jobs

JobTriggerPurpose
storage.file-objectenqueueFiling() from every finalize/land point, and from classify-and-draft once a kind is resolvedMove a landed file to its canonical key.
storage.delete-objectenqueued by the filer with startAfterRemove the superseded source. Gives the long-dead delete-s3-object handler a caller — previously registered but never enqueued, so superseded objects simply accumulated.
storage.pending-sweepdaily cron 0 3 * * *, no tenant on the tickResolve PENDING rows whose finalize call never arrived: object present ⇒ promote to LANDED and file it; object absent ⇒ delete the row.

The sweep runs an hour after deadline.scheduler so the two heavy daily passes do not contend. A tick carries no tenantId, so the handler fans out: it reads the tenant list under a bypass context and sweeps each tenant under its own RLS context, exactly like deadline.scheduler. One tenant throwing is logged and skipped rather than aborting the rest. Passing an explicit tenantId sweeps just that tenant, which is what you want when investigating one client.

Filing triggers

Every ingest path enqueues the filer. enqueueFiling() (apps/api/src/lib/filing.ts) is the single entry point, and it is deliberately forgiving:

  • Safe to call too early. If the row is not yet routable the filer leaves it in _inbox and does nothing.
  • Safe to call twice. An already-filed row is a no-op.
  • Never throws. A file that fails to enqueue is still a successfully uploaded file; the daily sweep and the next routing event will pick it up. Failing the request here would turn background tidying into a user-visible upload error.

That is what makes "enqueue on finalize" the right default everywhere — no route has to know whether classification has happened yet.

PathEnqueued atReason
Payroll portal — cycle filesfinalizeFINALIZE
Payroll portal — flat filesfinalizeFINALIZE
Payroll ops — Infotech output presign/finalizefinalizeFINALIZE
Payroll ops — output linked to a cyclePOST /ops/cycles/:id/outputsROUTING_CONFIRMED
Bookkeeping ops — manual documentfinalizeFINALIZE
Bookkeeping ops — bank statementfinalizeFINALIZE
Bookkeeping portal — intake + supportingfinalizeFINALIZE
Bookkeeping worker — classificationclassify-and-draftROUTING_CONFIRMED
FIN — trial balancefinalizeFINALIZE
XBRL — source documentfinalizeFINALIZE
IMM portal — KYC documentafter the server-side PUTFINALIZE
WhatsApp hook — inbound attachmentafter the server-side PUTFINALIZE
Staff upload (shared ops helper)finalizeFINALIZE
Pending sweep — recovered rowdaily cronFINALIZE

Two paths need no filing at all:

  • Generated artifacts (writeGeneratedArtifact) are written straight to their canonical key — payroll exports, FS DOCX, XBRL outputs, reconciliation reports, upload files.
  • Batch packaging files inline within the request, since the package is produced and filed in one transaction.

The POST /ops/cycles/:id/outputs case

An Infotech output arrives before anyone knows which cycle it belongs to, so it finalizes with no entity and the filer correctly leaves it in _inbox. Linking it to a cycle re-stamps tenantId/entityId/cycleIdthat is the moment it becomes routable, which is why the link route enqueues a second pass with reason ROUTING_CONFIRMED. It is the clearest example of why the filer must tolerate being called on an unroutable row.

The resulting tree

Verified end-to-end against the dev database and MinIO — ingest, generated artifact, unroutable file, re-file, and idempotent replay. These are real keys the pipeline produced, not illustrations:

breezycorp-prod/
└── spade-consulting/                                   ← tenantSlug

    ├── _inbox/2026/07/24/
    │   └── cmrymcwzn00060rllyaweoxdn.pdf               ← landed, not yet routable

    └── acme/                                           ← groupSlug
        └── zenith-trading-pte-ltd/                     ← entitySlug
            └── bookkeeping/                            ← product
                ├── documents/                          ← lane (ingested)
                │   ├── undated/
                │   │   └── BankStatement-20260724T072857Z.pdf
                │   └── 2026-04/                        ← period, once known
                │       └── April2026BankStatement-20260724T072857Z.pdf
                └── exports/                            ← lane (produced)
                    └── undated/
                        └── LedgerExport-20260724T072857Z.csv

The two documents/ entries are the same file before and after its batch was known: it filed as undated/BankStatement-… on finalize, then moved to 2026-04/April2026BankStatement-… when classification attached it to the April batch. previousObjectKey retains the old key and the superseded object is scheduled for deletion past the presign window — not removed inline, so a link already handed to a browser keeps working.

An upload with no resolvable entity stays in _inbox as LANDED. That is the designed outcome, not a failure: filing a document under a guessed owner is worse than leaving it unfiled for an operator.

Package map

LocationResponsibility
packages/storage/src/ (@breezycorp/storage)Pure — no AWS SDK, no env, no IO. Importable from contracts-tier code, tests and the browser build. Key building (keys.ts), period rendering (period.ts), the one sanitiser (sanitize.ts), slugs (slug.ts), Content-Disposition (http.ts).
packages/storage/src/client/ (@breezycorp/storage/client)The S3 surface. The only place S3_BUCKET and the credentials are read.
packages/contracts/src/enums/DocumentKind (what it is), FileKind (why it exists → which lane), StorageLane, ProductSegment.
packages/domain/src/storage/file-target.tsresolveFileTarget()THE single place a key is computed. Writers and the filer both go through it.
packages/domain/src/storage/generated-artifact.tswriteGeneratedArtifact() — direct-to-canonical writes.
apps/api/src/lib/upload-ceremony.tsreserve → complete. The key never leaves the server.
apps/api/src/lib/filing.tsenqueueFiling() — the one way a route asks for a filing pass.
apps/worker/src/handlers/storage/The filer, the deferred delete, the pending sweep + its daily fan-out.

DocumentKind is duplicated as a Prisma enum; packages/db/src/__tests__/document-kind-parity.test.ts fails the build if the two drift.

Safety rails

  • ObjectKey is a branded type. A raw string from a request body cannot be passed where a key is expected. The only ways to obtain one are the builders or assertSafeKey.
  • assertSafeKey percent-decodes first (%2e%2e%2f is ../ to anything that URL-decodes, and presigned URLs are built from decoded keys), then rejects traversal, disallowed charset, bad shape (leading/trailing /, //) and over-length.
  • 900-byte cap, not S3's 1024. A key that would overflow has its stem truncated with a short digest appended, so it stays unique and writable — a key that cannot be written is an upload that silently fails.
  • Unknown enum values never throw on the storage path. laneFor falls back to documents, productSegmentForCode to shared, unknown classifier labels to DocumentKind.OTHER. A hard failure here would drop a document.
  • DocumentRouting keeps the classifier's literal output forever (documentTypeRaw) alongside the normalized documentKind. An enum column alone would reject an unseen label at insert time and drop the inbound document — the worst failure mode for a single front door.

Two file spines

File and PayrollFile are both file tables. PayrollFile is payroll in name only — every bookkeeping document lives there too. They are kept deliberately isomorphic (same lifecycle columns, same resolver, same filer) so that merging them later is a mechanical migration rather than a redesign.

Deprecated columns

Two columns are named …FileId but hold raw storage keys, and were passed straight to S3 as Key:. Both now have a real FK alongside them, retained until the backfill has populated every row:

DeprecatedReplacement
BookkeepingBatch.uploadFileIduploadFileRefIdPayrollFile
ReconciliationRun.reportFileIdreportFileRefIdPayrollFile

Similarly, FsExportArtifact replaces the objectKey startsWith 'fin/<entity>/<engagement>/exports/' prefix scan that was the FS artifact list — a query that made the key the join key and silently returned nothing the moment a key moved. It mirrors the existing xbrl_output_artifacts so FS and XBRL are symmetric.

Configuration

Env varDefaultPurpose
S3_BUCKETbreezycorpRead in exactly one place.
S3_ENDPOINT / S3_REGION / S3_ACCESS_KEY / S3_SECRET_KEY / S3_FORCE_PATH_STYLEStandard S3 config.
S3_PRESIGN_TTL_SECONDS600Default presigned-URL lifetime.
S3_MAX_PRESIGN_TTL_SECONDS900Ceiling on any presigned URL, and therefore the grace period before the filer deletes a moved object.

Internal use only — BreezyCorp