Object storage — the provider-agnostic seam¶
Last reviewed: 2026-09-07
All object storage rides one seam per language, speaking the S3 API. There is no MinIO SDK anywhere anymore; portability to another provider (GCS today, anything S3-compatible tomorrow) is configuration, not code.
| Language | Package | Backed by |
|---|---|---|
| TypeScript | shared/storage/ → @trueserve/storage (packed tarball, file: dep) |
@aws-sdk/client-s3 + s3-request-presigner + lib-storage |
| Python | libs/storage/ → trueserve_storage (sys.path bootstrap per app, like libs/knowledge) |
boto3 |
| Go | apps/media-proxy/internal/storage/ |
aws-sdk-go-v2 |
Roles and buckets¶
A service never names a bucket in code — it asks for a role:
| Role | Env var | Contents | Writers |
|---|---|---|---|
recordings |
STORAGE_BUCKET_RECORDINGS |
conversations/<id>/{caller,agent,mixed}.wav, conversation image/document attachments, whisker-traces/, transcriptions/ staging, workflow-datasets/ |
ai-orchestrator, media-proxy, conversation-api, async-agent |
catalogs |
STORAGE_BUCKET_CATALOGS |
catalogs/<scope>/<slug>/… bundles, plugins/, previews/ |
catalog-builder, ai-manager (import) |
assets |
STORAGE_BUCKET_ASSETS |
surface-configs/images/, Teams agent icons |
ai-manager |
scale-testing |
STORAGE_BUCKET_SCALE_TESTING |
scale-testing job files + recordings | ai-manager |
Buckets are never created at runtime: the seam has no ensureBucket, and the
runtime IAM policy deliberately omits s3:CreateBucket/s3:ListAllMyBuckets (a
CloudTrail CreateBucket event from a service is a regression).
There are two naming schemes, and which one applies depends on who provisioned the bucket — the name is not a convention you may derive.
| Where | Actual bucket names | Provisioned by |
|---|---|---|
local — devcontainers + city EC2s (shared buckets, separated by STORAGE_KEY_PREFIX) and Kind |
agent-studio-local-<role> |
scripts/storage/create-buckets.sh |
KMS dev / qa / prod (AWS EKS) |
trusrv-<env>-orch-<role> |
Terraform — local.resource_prefix = "${customer}-${environment}-${application}" in infra/stacks/aws-kms/<env>/main.tf |
create-buckets.sh documents the agent-studio-<env>-<role> pattern for all four
envs, but it was never run against the KMS accounts — those buckets predate the
seam and live under the Terraform names. On KMS the bucket name is therefore
constrained twice over: the bucket must exist under the Terraform-derived name, and
that exact ARN must appear in the env's IRSA policy (S3AppBucketsAccess in
infra/stacks/aws-kms/<env>/main.tf), which enumerates the app buckets rather than
wildcarding a prefix. Editing STORAGE_BUCKET_* in a values-kms-<env>.yaml to a
name Terraform doesn't produce breaks that env — twice: the bucket 404s and it is
outside the grant. A genuine rename is a three-sided change (values + Terraform +
IAM), and it must be verified per env, because these values files are per-env and a
fix to one does not touch the others.
This has bitten twice. PR #646 renamed STORAGE_BUCKET_* to agent-studio-<env>-* in
all three KMS values files; the follow-up reverted only values-kms-dev.yaml on the
assumption that qa/prod were already correct, leaving both pointing at nonexistent
buckets. QA surfaced it as NoSuchBucket on every catalog publish, which failed
ArgoCD's PostSync catalog-builder-seed-builtins hook — an ArgoCD "Sync failed" on an
App that was otherwise Healthy and Synced. grep -rn "agent-studio-" charts/ is the
check; only values-kind.yaml's agent-studio-local-* should match. See
KMS_DEPLOYMENT_CHANGELOG.md Sessions
112 and 115.
The assets bucket is the newest of the four and was missing everywhere until
2026-09-07: STORAGE_BUCKET_ASSETS had named trusrv-<env>-orch-assets in all three
KMS values files since the seam landed, but no stack ever provisioned it and the role
was absent from the IRSA grant, so every surface-config image upload and Teams agent
icon write failed with NoSuchBucket on dev, qa and prod. module "s3_assets" now
provisions it per env alongside the ARNs in S3AppBucketsAccess. It deliberately
carries no CORS rules (nothing reaches those objects from the browser — ai-manager
streams them through /api/surface-configs/images/<key>) and no lifecycle expiry (an
icon must outlive the agent version referencing it).
Object keys are unchanged from the MinIO era and must stay byte-identical: cloned A2UI catalog versions share bundle objects by absolute key, so re-keying silently detaches clones from their bundles.
Configuration surface¶
Every MINIO_*/S3_* alias is gone (CI gate:
scripts/storage/check-retired-storage-vocabulary.sh).
The canonical vars:
| Var | Required | Meaning |
|---|---|---|
STORAGE_PROVIDER |
no | s3 (default) | gcs | local (filesystem — tests) |
STORAGE_BUCKET_<ROLE> |
per role used | no default — a missing bucket is a loud failure, never a silent wrong-bucket write |
STORAGE_REGION |
yes (s3/gcs) | always passed explicitly to the client |
STORAGE_ENDPOINT |
no | empty ⇒ AWS S3; https://storage.googleapis.com for GCS interop; http://localhost:9000 for the CI MinIO |
STORAGE_FORCE_PATH_STYLE |
no | true for any custom endpoint (MinIO, GCS) |
STORAGE_ACCESS_KEY / STORAGE_SECRET_KEY / STORAGE_SESSION_TOKEN |
no — keep it that way | absent ⇒ SDK default credential chain (IRSA web-identity on EKS, instance profile, GCS HMAC via secret). Never wrap these in a required-env helper: the absent-key path IS how KMS pods authenticate |
STORAGE_KEY_PREFIX |
no | prepended to every key — separates shared-local-bucket users |
LOCAL_STORAGE_PATH |
no | base dir for the local provider |
Cross-cloud (GCS)¶
GCS is reached through its S3-interop XML API: STORAGE_ENDPOINT=https://storage.googleapis.com,
STORAGE_FORCE_PATH_STYLE=true, HMAC keys as STORAGE_ACCESS_KEY/STORAGE_SECRET_KEY
(provisioned by charts/trueserve-stack/templates/external-secrets/storage.yaml).
charts/trueserve-stack/values-cloud-gcp.yaml carries the wiring.
Because daily dev runs against real AWS S3, the custom-endpoint + path-style path —
the only route to GCS — is exercised by the storage-conformance CI job
(.github/workflows/test.yml): the same TS + Python conformance suites run against a
MinIO service container with STORAGE_TEST_ENDPOINT + path style. That job is the
standing guard on the cross-cloud claim; don't remove it to "speed up CI".
Presigned URLs and IRSA¶
A presign signed with STS temporary credentials dies when the credential expires
(~1 h) regardless of the window requested. Rules of thumb: attachment presigns are
1 h (conversation-api/storage.py); the catalog-bundle 12 h window survives only on
static-credential environments (documented caveat in catalog_manifest.py); never
store a presigned URL.
Per-tenant archive sinks¶
Conversation archive sinks carry their own S3-compatible config
(endpoint/keys/bucket, tenant data with legacy alias key names — those alias keys
are DATA compatibility and stay). Both seams expose a constructor for it
(createStoreFromS3CompatibleConfig / create_store_from_s3_compatible_config), and
the archive copy is streamed across stores with a server-side CopyObject fast
path only when source and destination resolve to the same client.
Legacy bucket names in data¶
DB rows (conversation_attachments.storage_bucket, archive manifests) still carry
pre-migration bucket names. roleForStoredBucket/role_for_stored_bucket maps
conversations → recordings, trueserve-catalogs → catalogs, trueserve → assets
(the objects were synced key-for-key into the role buckets); unknown names pass
through as explicit buckets (tenant sinks).
See also¶
- cloud-managed-swap.md — the provider swap matrix
- Runbook: migrate object storage to S3
- Env reference