Flow
ServicesDocuments Service

Operations

Storage account configuration, Service Bus topology, Helm releases, and the pipeline for the documents service.

The documents service is in DEPLOYED_SERVICES, so its paths are part of the merged platform OpenAPI document and its .azure/ package is the one that runs.

Storage accounts

The catalog is non-secret and lives in services/documents/.azure/env/config.dev.yaml:

KeyDev value
DOCUMENTS_STORAGE_ACCOUNTSflowintelprivatedev01
DOCUMENTS_STORAGE_FLOWINTELPRIVATEDEV01_VISIBILITYprivate
DOCUMENTS_STORAGE_FLOWINTELPRIVATEDEV01_CONTAINERknowledge-storage,project-storage,agent-storage
DOCUMENTS_STORAGE_FLOWINTELPRIVATEDEV01_SAS_TTL15m

_CONTAINER is a comma-separated list, so one Azure storage account can serve several containers under a single catalog entry. The list is an allow-list rather than a default: a request names one of the containers on it, and a request naming anything else gets 422. The set of containers a caller can reach is therefore exactly the set written in configuration.

Where an account owns a single container, a request may leave container out and gets that one. Where it owns several, a request must name one — there is no safe default, and picking one would put a document somewhere the caller did not ask for.

The per-account key name is the account name upper-cased with every non-alphanumeric character replaced by _, so adding archive-cold means DOCUMENTS_STORAGE_ARCHIVE_COLD_* and appending the name to DOCUMENTS_STORAGE_ACCOUNTS. Visibility, container, and connection string are required: an account listed without them fails startup rather than serving requests it cannot sign for. Container names are checked against Azure's naming rules at startup too, so a typo is a startup failure that names the setting rather than an error on first upload. _SAS_TTL is optional, defaults to 15 minutes, and is capped at 24 hours.

DOCUMENTS_STORAGE_<ACCOUNT>_CONNECTION_STRING is the one secret per account and belongs in the SOPS secrets file, never in config.*.yaml. Layout: services/documents/.azure/env/readme.md.

Changing a SAS lifetime

*_SAS_TTL is read at startup and applied when a URL is signed, so a change takes effect for new SAS URLs after a restart. Already-issued URLs keep the lifetime they were signed with — there is no revocation short of rotating the account key.

Adding an account

  1. Create the storage account and its containers in Azure.
  2. Add the connection string to the SOPS secrets file for that environment.
  3. Add the visibility, container, and TTL keys to config.<env>.yaml, and the name to DOCUMENTS_STORAGE_ACCOUNTS.
  4. Deploy. cmd/api calls CreateIfNotExists for every container in the list at startup, so step 1's containers are belt-and-braces.

Adding a container to an existing account is steps 3 and 4 alone: append it to that account's _CONTAINER list and deploy.

Removing an account is not symmetric: documents that name it keep naming it, and reads of those documents return 503 until the account is reachable again. Migrate the rows first.

Upload cap

DOCUMENTS_MAX_UPLOAD_BYTES sets the largest blob that can be registered. The default is 256 MiB. The setting is optional, but if it is present and is not a positive whole number, the service refuses to start rather than ignoring it. Someone who sets a limit should be able to assume it took effect.

The limit is checked when the document is registered, not while the bytes are being written, because a SAS URL cannot carry a size limit. So a caller can upload something too big and only find out afterwards. That upload is refused with 413, never becomes a document, and its bytes are removed by the reaper below.

Orphan reaper

Every upload takes two calls. Between handing out a SAS URL and registering the document, there is a blob in storage with no database row. A caller who gives up halfway leaves that blob behind forever.

POST /internal/jobs/reap-orphans deletes blobs in the configured containers that have no document row and have not been touched for DOCUMENTS_ORPHAN_AGE, which defaults to six hours. It makes one pass per container and reports a result for each, because a container's listing is only meaningful against the document rows for that same container.

The window is that long on purpose. Deleting a blob while its registration call is still in flight would break a caller doing exactly what the API told it to do. Each run deletes at most 500 blobs and is safe to repeat, so a backlog clears over several runs.

Deletion only goes one way. A blob with no row is rubbish and gets removed. A row with no blob is a bug, and the reaper leaves it alone rather than tidying the evidence away.

The route uses the shared scheduler secret as Authorization: Bearer (ADR 0026) and is not in the OpenAPI document. If SCHEDULER_TOKEN is unset, the route is never registered and returns 404. A process that never received the secret has no reaper at all, rather than one anybody can trigger.

Service Bus topology

services/documents/.azure/bus/topology.yaml declares one domain:

DomainTopicSubscriber queues
documentsdocuments-documents-fanoutdocuments-documents-queue-listener

documentsListener is the only consumer. Only name a subscriber that has a reader — a queue nobody consumes fills until its messages dead-letter. Every subscriber receives every core event from the documents outbox — documents.created and documents.updated. Append another name under subscribers to add a queue. There is no PII topic: neither event type declares PII, and payloads carry filenames and reference ids rather than file contents. See ADR 0005.

Environment namespace and per-domain fanout/queue settings: services/documents/.azure/bus/bus.dev.yaml. The publisher embeds the same definition via services/documents/messageBus/topology_gen.go, generated by make generate.

One image, several releases

Helm releaseBinaryChart
documents-api/service (default)k8s/
documents-documentslistener/documentsListenerk8s/

Dev API ingress: documents.dev.apps-and-services.com (services/documents/.azure/k8s/values/api.dev.yaml); prod is documents.apps-and-services.com.

Pipeline

services/documents/.azure/azure-pipelines.yml triggers on develop / master changes under services/documents. Stages match template's: decrypt SOPS secrets, build the image from the repository root, busctl plan (exit 2 additive, exit 3 destructive and gated by a manual approval), apply, then helm upgrade --install per chart.

Migrations

A Helm pre-upgrade Job runs the API binary with -migrate. Secrets arrive as a ConfigMap at deploy time and are never baked into the image.

0002_documents.sql has no down: forward-only, like every migration on the platform.

What to look at when uploads fail

SymptomWhere to look
422 on create, "blob is not present"The caller skipped the SAS PUT, or the SAS expired before the upload finished — check *_SAS_TTL against the file sizes callers send
422 "unknown storage account"DOCUMENTS_STORAGE_ACCOUNTS and the per-account keys for that environment
503 "storage is not configured"The process started without any DOCUMENTS_STORAGE_* keys
503 naming a specific accountThat account was removed from config while documents still reference it
Events not arrivingThe outbox: unpublished rows in documents_events, and the publisher's advisory lock

Unpublished outbox rows and queue depths are readable from the DevOps console once this service is deployed.

On this page