Architecture
Binaries, tables, the storage-account catalog, and where a SAS is signed in the documents service.
Binaries
| Binary | Path | Role |
|---|---|---|
| API | cmd/api | HTTP server + outbox publisher; flags -migrate, -openapi |
| documentsListener | cmd/documentsListener | Consumes 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 queriesThe 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:
| Table | Holds |
|---|---|
documents | visibility, storage account, container, blob name, filename, content type |
documents_references | ref_type, ref_id, ref_category, is_external |
documents_metadata | meta_name, meta_value |
documents_event_types | documents.created, documents.updated |
documents_events | the outbox |
documents_commands | idempotency 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 freshIdempotency-Keycannot 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 a422rather 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.
| Endpoint | Permission | Signed |
|---|---|---|
POST /v1/documents/sas | write (create + write) | Before any DB work; no row is written |
GET /v1/documents/{id} | read | In 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 source
flowchart LR
Client --> Chi
Chi --> Huma
Huma --> BlobCheck[blob exists?]
BlobCheck --> Command
Command --> Domain
Domain --> SQL
Command --> Outbox
Outbox --> PublisherThe 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/...