Flow
ServicesDocuments Service

Architecture

Binaries, tables, the storage-account catalog, and where a SAS is signed in the documents service.

Binaries

BinaryPathRole
APIcmd/apiHTTP server + outbox publisher; flags -migrate, -openapi
documentsListenercmd/documentsListenerConsumes documents-documents-queue-listener and logs each message

Layout

services/documents/
  domains/
    documents/events/         ← public event payload contracts
  internal/
    domains/documents/        ← rows, child collections, outbox event
    storage/                  ← account catalog, SAS signing, blob checks
    handlers/                 ← per-operation types and bodies (auth not wired yet)
    command/                  ← idempotency + transaction
    publisher/                ← drain the documents outbox to the broker
    listener/                 ← documentsListener consumes + logs
    testdb/                   ← this service's migrations, over common/testdb
  messageBus/                 ← generated fanout topology
  migrations/
  db/                         ← sqlc generated queries

The platform mechanics are in common/ — see ADR 0018 and ADR 0019. What is specific to this service is its schema, its outbox, its topology, and internal/storage/.

Postgres is the record

services/documents/migrations/0002_documents.sql follows ADR 0015:

TableHolds
documentsvisibility, storage account, container, blob name, filename, content type
documents_referencesref_type, ref_id, ref_category, is_external
documents_metadatameta_name, meta_value
documents_event_typesdocuments.created, documents.updated
documents_eventsthe outbox
documents_commandsidempotency claims and stored responses

Two constraints carry most of the safety:

  • (storage_account, container, blob_name) is unique, so one blob backs at most one document. A create retried with a fresh Idempotency-Key cannot register the same uploaded bytes twice, and a caller cannot aim a second record at a blob that already belongs to someone else's document.
  • (document_id, ref_type, ref_id, ref_category) is the reference primary key, and (document_id, meta_name) the metadata one. Duplicates in a request body are a 422 rather than a silently collapsed set.

References are indexed on (ref_type, ref_id, ref_category), which is what the list filters read.

Event payloads: services/documents/domains/documents/events/events.go. Business logic: services/documents/internal/domains/documents/create.go.

References are opaque here

A reference says a document belongs to ref_type lease, id L-1, in the role signed-copy. This service does not resolve ref_type to a service, does not call anyone to check that L-1 exists, and has no foreign key to anything outside its own database. is_external records that the id came from a system outside the platform, so a caller reading the row knows not to look for it internally.

That is deliberate: it is what lets any service attach documents without this service learning about it, and it is why a stale reference is the referring service's problem to clean up.

The storage-account catalog

Storage account catalog: The named Azure Storage accounts the documents service can write to, each with a visibility, one container, and a SAS lifetime. Configuration rather than a database table; callers name an account and never a container. is configuration, loaded once at startup by services/documents/internal/storage/catalog.go from DOCUMENTS_STORAGE_* environment variables. It is not a table, because it changes with a deploy and not with a request.

An account is a name, a visibility, a list of containers, and a SAS lifetime — plus a connection string that stays inside internal/storage. Handlers receive a resolved storage.Location, never a credential.

Callers name the account, the visibility, and the container they intend. A disagreement with the catalog is rejected on any of the three. A private document landing in a public container therefore needs two independent mistakes rather than one guessed account name.

The container list is an allow-list. A caller may name one of the containers configured for the account and nothing else, so the reachable set is exactly what an operator wrote down and a guessed name reaches nothing. Account.Locate is the only way a container name becomes a storage.Location, and every call that reaches Azure takes a Location rather than an account and a loose string — so the check is not something a new call site can forget to make.

Where a SAS is signed, and where it is not

Signing is a local HMAC over the account key — no Azure round trip — in services/documents/internal/storage/sas.go. The lifetime comes from the account's configured TTL.

EndpointPermissionSigned
POST /v1/documents/saswrite (create + write)Before any DB work; no row is written
GET /v1/documents/{id}readIn the handler, after the query
GET /v1/documents—Never
GET /v1/documents/readable—Never
GET /v1/documents/uploaded-count—Never

The read SAS is minted outside command.Execute and never persisted. A SAS stored in documents_commands.response_body would be handed back, long expired, on every replay of that command. The same reasoning keeps SAS URLs out of list results, where most of them would expire unused.

Request path

Diagram. Text representation: flowchart LR Client --> Chi Chi --> Huma Huma --> BlobCheck[blob exists?] BlobCheck --> Command Command --> Domain Domain --> SQL Command --> Outbox Outbox --> Publisher
Diagram source
flowchart LR
  Client --> Chi
  Chi --> Huma
  Huma --> BlobCheck[blob exists?]
  BlobCheck --> Command
  Command --> Domain
  Domain --> SQL
  Command --> Outbox
  Outbox --> Publisher

The blob check on create happens before BEGIN. A transaction must never wait on the network, and confirming the bytes exist is what stops a document row from describing an upload that never happened. A missing blob is a 422 and writes nothing.

internal/storage's blob access is behind a Blob interface (services/documents/internal/storage/blob.go), so handler tests run with a fake and CI needs neither Azure nor Azurite.

Container creation

cmd/api calls CreateIfNotExists for every configured container at startup. Locally that is what makes a fresh Azurite volume work with no setup step; deployed, it makes a new account a config change rather than a ticket.

On Azurite it also sets a blob-service CORS rule. The review console PUTs bytes at the SAS URL from another origin (localhost:3503 to 127.0.0.1:10000), and the emulator has no CORS rules of its own. Real Azure accounts keep CORS in the portal; startup does not touch them.

Logging

One line per finished request, with serviceDomain narrowed to documents-documents and command set from the matched operation. Format follows APP_ENV: console for local, JSON otherwise, both on stderr because cmd/api -openapi owns stdout.

requestId is written into the outbox row's headers inside the producing transaction, published as the broker correlation id, and logged again by the listener. See ADR 0017.

Tests

go test ./services/documents/...

On this page