Attachments and object storage
Status: Phase 30 complete (roadmap) — slices 1–3 delivered. Delivered: attachment core on a durable
BlobStorewith a local default (slice 1); the opt-intesseraql-s3leaf module for S3 and S3-compatible stores (slice 2); the scan-hook SPI (synchronous scan, download gated on clean) and age-based retention (slice 3). Remaining refinements (not blocking the phase): app-mode 2-way SQL metadata, theattachmentcoverage kind, and orphan-GC (needs aBlobStorelisting capability). This completes the attachments leg of Milestone M9.
Attachments let a business record carry files — an invoice PDF, a scanned form, a product image —
stored as durable objects outside the database and addressed from SQL. The phase adds three things:
a durable object-store SPI (BlobStore) with a local default and an opt-in S3 implementation; a
record-attachment recipe that streams uploads off-heap, writes metadata through 2-way SQL, and
serves downloads under the same authorization a query gets; and the governance (deny-by-default
egress, virus scanning, retention) that keeps it safe.
It builds on existing subsystems rather than inventing parallel machinery:
- The spool/
TempStoremodel (tesseraql-coreio.tesseraql.core.spool) — the off-heap streaming primitives (SpoolWriter,SpoolRef) that already keep large payloads out of the heap. - The file-transfer machinery (
FileTransferService/JdbcFileTransferService) — the asynchronous, off-heap, operations-tracked transfer path behindfile-import/file-exportand the Phase 26poll:trigger. Uploads ride it; downloads stream from the store the same way. - 2-way SQL + policies + scoping — attachment metadata is a row, so reading an attachment is a
SELECTunder the samepolicy:and Phase 29/*%scope ... */machinery every other query gets. Authorization is never a second, bespoke code path (see data-scoping.md). - The SecretResolver SPI (
io.tesseraql.yaml.secret) — object-store credentials resolve from${secret.env.*}/${secret.file.*}lazily, never logged, never written to an artifact. - The retention sweeper (
RetentionSweeper, design ch. 44) — orphaned and aged blobs are reclaimed by the same cluster-safe timer that purges the outbox and job history. - The opt-in leaf-module pattern (tesseraql-oidc,
tesseraql-pdf) — the heavy object-store SDK lives in a leaf module apps install only if they need it.
Why a new BlobStore, not the TempStore SPI
Section titled “Why a new BlobStore, not the TempStore SPI”The TempStore SPI (io.tesseraql.core.spool.TempStore) is for ephemeral spool: rowsets,
export intermediates, CSV/JSONL buffers. Its lifecycle is create → read once → delete; SpoolRef
carries a row count, and the store is free to reclaim a spool as soon as a transfer finishes.
Attachments are the opposite: durable, addressable, retained business objects whose deletion is
governed by retention, never by the store. Overriding TempStore with an S3 implementation — the
naive reading of “S3-compatible TempStore” — would push spool traffic into object storage and
conflate two lifecycles that must stay separate.
So Phase 30 introduces a sibling SPI, BlobStore (io.tesseraql.core.blob), for durable
objects, and reuses the spool streaming primitives (off-heap write, checksum-as-you-stream) rather
than the spool store. The two SPIs coexist: a deployment can keep spool on local disk and put
attachments in S3, or vice versa. (An app that genuinely wants spool in S3 can also install an
S3TempStore from the same leaf module — a one-line bonus, not the main event.)
package io.tesseraql.core.blob;
public interface BlobStore {
/** Opens a writer streaming bytes to a new durable object; its ref is available on close. */ BlobWriter createWriter(BlobSpec spec); // spec: contentType, optional filename hint
/** Opens an input stream over a stored object. */ InputStream openInput(BlobRef ref) throws IOException;
/** Whether the object still exists. */ boolean exists(BlobRef ref);
/** Deletes the object, ignoring a missing target. Called only by retention, never implicitly. */ void delete(BlobRef ref);
/** A short-lived pre-signed GET URL, or empty if the provider does not support one. */ Optional<URI> presignGet(BlobRef ref, Duration ttl);}BlobRef is an immutable record — key, contentType, byteSize, checksum (SHA-256 hex,
computed while streaming), createdAt — and never carries the bytes. BlobWriter mirrors
SpoolWriter: write(...) during streaming, toRef() after close(). The surface is deliberately
the minimal portable intersection of every S3-compatible store: put, get, exists, delete, and an
optional presign. Tagging, ACLs, object-lock, and lifecycle rules are not on the SPI, because they
vary across providers — keeping them off the interface is what makes portability hold (see
S3-compatible storage).
Two built-in implementations:
FileBlobStore(tesseraql-core, default) — durable objects under{appHome}/work/blob/…,java.nioonly, no new dependency. Works out of the box; the local-development and single-node default.S3BlobStore(tesseraql-s3leaf module, slice 2) — AWS SDK for Java v2, confined to the opt-in module so its weight never reaches an app that does not store blobs in S3.
The record-attachment recipe
Section titled “The record-attachment recipe”An attachment is a durable blob plus a metadata row that ties it to a business record. The
metadata follows IAM’s managed/app duality (like WorkflowStore and OrgUnitStore), selected per
document or globally by tesseraql.attachments.mode:
managed(default) — the runtime provisions and owns a generictql_attachmenttable (id,entity,entity_id,filename,content_type,byte_size,checksum,storage_key,scan_status,created_by,created_at) behind anAttachmentStoreSPI (tesseraql-core) with aJdbcAttachmentStoreimpl (tesseraql-operations). Unlike workflow state (often already a column) or org units (an app may already have them), attachment metadata is a brand-new generic concern with no pre-existing app table, so the managed table is the low-friction default.app— the application owns its own attachment columns/table and supplies theinsert:/select:2-way SQL; nothing managed is provisioned. For apps that want attachments first-class in their own schema.
A kind: attachment document under attachments/ declares the binding and synthesizes two
routes — an upload POST and a download GET — the way kind: workflow synthesizes a route per
transition:
version: tesseraql/v1id: invoice_fileskind: attachmentbasePath: /invoices/{invoiceId}/filesrecord: { entity: invoice, key: invoiceId } # the owning record, bound from the pathbucket: app-uploads # a logical bucket; see tesseraql.object-storagelimits: maxBytes: 25MB contentTypes: [application/pdf, image/png, image/jpeg]scan: require-clean # gate downloads on a clean scan (slice 3)# managed mode needs nothing more; app mode adds:# metadata:# mode: app# insert: sql/attach_invoice_file.sql # /* audit.* */ binds allowed# select: sql/fetch_invoice_file.sql # /*%scope ... */ for row authorizationUpload — off-heap, then commit
Section titled “Upload — off-heap, then commit”POST {basePath} (multipart) runs the same path as file-import:
- The body streams off-heap through the file-transfer machinery; SHA-256 and byte size are
computed during the stream (never materialized in memory), and
limitsare enforced as bytes flow. - The durable object is written to the
BlobStore, yielding aBlobRef. - The metadata row is inserted in a DB transaction — managed (
AttachmentStore.record(...)) or the app’sinsert:2-way SQL — bindingstorage_key,content_type,byte_size,checksum, the record reference, and the canonical/* audit.user *///* audit.now */binds (Phase 18).
The blob write (step 2) is not transactional — like the Phase 26 http-call step and the
Phase 27 outbox, an external side effect cannot be rolled back. If the transaction in step 3 fails or
the request aborts after step 2, the blob is orphaned, and retention’s orphan-GC reclaims it
(see Retention). This is the same “commit the record, reconcile the side effect”
discipline the framework already uses, not a new failure model.
Download — authorization is a SELECT
Section titled “Download — authorization is a SELECT”GET {basePath}/{attachmentId} is where the SQL-first design pays off:
- The framework runs the metadata
SELECT— managed default or the app’sselect:— under the route’spolicy:and any/*%scope ... */directive it carries. If the caller may not see the row, the query returns nothing and the download is404(403when policy denies outright). Attachment access control is therefore the same SELECT under the same policy and Phase 29 row scoping every other read gets — there is no second authorization path to get wrong. - If
scan: require-cleanand the row is notscan_status = clean, the download is refused (409). - The blob streams from the
BlobStorewithContent-Disposition: attachment; filename="…"and the storedcontent_type— or, when the provider supports it and presigning is enabled, a302to a short-lived pre-signed GET URL so the bytes never transit the app. The presign is issued only after step 1 authorizes, so offloading transfer never bypasses authorization.
A job pipeline may also gain an attach: step (a later refinement) so a batch can attach a
generated file — e.g. a Phase 21 pdf export — to a record. It composes with the BlobStore and
AttachmentStore exactly as the route does.
S3-compatible storage
Section titled “S3-compatible storage”The tesseraql-s3 leaf module (slice 2) ships S3BlobStore on AWS SDK for Java v2, whose
S3Client targets any S3-compatible store — AWS S3, Cloudflare R2, Ceph RGW, Backblaze B2, Wasabi —
by overriding the endpoint. One module covers AWS and every compatible store; there is no
per-provider module.
tesseraql: object-storage: provider: s3 # default: file (FileBlobStore) buckets: app-uploads: { bucket: acme-app-uploads } # logical name → real bucket s3: endpoint: https://s3.us-east-1.amazonaws.com # or https://<acct>.r2.cloudflarestorage.com, http://minio:9000 region: us-east-1 # required by the signer even for compatible stores (a dummy is fine) pathStyle: false # true for MinIO/Ceph; false for AWS/R2 checksumMode: when-required # see the checksum note below credentials: accessKey: ${secret.env.OBJECT_STORE_ACCESS_KEY} secretKey: ${secret.file.object_store_secret_key} allowedBuckets: # deny-by-default egress allow-list (mirrors http.outbound) - acme-app-uploadsCompatibility settings that the config exposes because they bite in practice:
pathStyle— AWS and R2 accept virtual-hosted-style (bucket.endpoint); MinIO and Ceph generally need path-style (endpoint/bucket).region— the SigV4 signer requires a region even when it is meaningless to a compatible store; defaultus-east-1.checksumMode— AWS SDK v2 (2.30+) defaults to adding CRC32 request checksums, which some compatible stores reject.checksumMode: when-requiredmaps torequestChecksumCalculation = WHEN_REQUIRED/responseChecksumValidation = WHEN_REQUIRED, restoring compatibility;defaultkeeps the SDK behavior for AWS.
S3BlobStore is contributed by a BlobStoreProvider discovered through ServiceLoader (the
PdfEngine/FileCodec idiom) and selected when provider: s3, binding itself as the BlobStore. (A
RuntimeExtension would install too late — after the attachment service is constructed — so the
provider factory, resolved inside the same wiring step, is the right seam.) Switching provider
is the whole change — no DSL touches the bytes, so an app moves from local disk to S3 by config
alone.
Governance
Section titled “Governance”Object storage is egress, so it is deny-by-default like outbound HTTP and poll connectors:
tesseraql.object-storage.allowedBucketsis an explicit allow-list; an attachment document whosebucketresolves to a bucket not on the list fails the build (it is never silently served).- Credentials resolve through the SecretResolver at call time, never inlined, never logged, never written to a generated artifact (rule 6).
- The Camel/SDK client stays an implementation detail — apps see the
bucket/limits/scanrecipe surface, never raw SDK options (extension principle 2).
Scanning
Section titled “Scanning”A scan-hook SPI, AttachmentScanner (tesseraql-core io.tesseraql.core.scan,
ServiceLoader-discovered via AttachmentScanners.discover()), is the seam for ClamAV or a cloud
malware-scan service. The default is a no-op that reports clean without reading the bytes, so an
app enables real scanning by adding a scanner module — no config flag.
package io.tesseraql.core.scan;
public interface AttachmentScanner { String id(); ScanVerdict scan(BlobRef ref, ContentSource content); // CLEAN | INFECTED | ERROR}Scanning is synchronous on upload: the scanner runs on the stored object (the content is supplied
lazily, so the no-op default never opens the blob), and the verdict is recorded as scan_status. A
CLEAN object is served normally; an INFECTED object is recorded infected and never served
(the download gate refuses any non-clean object with 409), and is kept or removed per
tesseraql.attachments.scan.onInfected: quarantine | delete (default quarantine); a scanner
ERROR fails the upload closed (503). (An asynchronous pending → scanned model is a possible
later refinement; synchronous keeps the gate simple and needs no scan sweeper.)
Retention
Section titled “Retention”Attachment retention wires into the ch. 44 RetentionSweeper (the cluster-safe timer that already
purges the outbox and job history), gated by tesseraql.retention.attachments:
- Age policy (delivered) — attachment metadata past the configured window is deleted and each blob reclaimed (best-effort, so a concurrent node or an already-removed blob is harmless). Driven by the sweep rather than provider lifecycle rules on purpose: server-side lifecycle support varies across S3-compatible stores, so the portable, observable path is the in-app sweep.
- Orphan GC (deferred) — reclaiming a blob whose
storage_keymatches no metadata row needs a store-listing capability the minimalBlobStoreSPI does not yet expose; the upload path’s best-effort delete-on-failure already covers the common case. A later refinement.
Lint and coverage
Section titled “Lint and coverage”Machine-checkable like every other recipe (extension principle 4). The attachment document gets its
own lint family (TQL-ATTACH-34xx, mirroring the TQL-WORKFLOW/TQL-SCOPE per-kind families rather
than crowding the TQL-YAML loader-error number space); object-storage egress in slice 2 takes
TQL-SEC-411x (TQL-SEC-4100 is reserved by Phase 29 for write-scope bypass):
| Code | Severity | Meaning | Status |
|---|---|---|---|
TQL-ATTACH-3401 |
error | a kind: attachment document does not declare kind: attachment |
slice 1 |
TQL-ATTACH-3402 |
error | a kind: attachment document is missing basePath |
slice 1 |
TQL-ATTACH-3403 |
error | a kind: attachment document is missing record.entity/record.key |
slice 1 |
TQL-ATTACH-3404 |
error | the basePath does not contain the record key as a path parameter |
slice 1 |
TQL-ATTACH-3405 |
error | limits.maxBytes is missing or unparseable |
slice 1 |
TQL-SEC-4110 |
error | with provider: s3, an attachment’s resolved bucket is not in tesseraql.object-storage.allowedBuckets (deny-by-default) |
slice 2 |
Scanning (slice 3) adds no lint — it is config + a ServiceLoader scanner, with no new YAML surface — and is enforced at runtime instead (the codes below).
The runtime fails closed, all mapped from the TQL-LD-284x codes the processors/service raise: a
404 when an attachment is unknown or owned by a different record (never leaked across records), a
413 past the size limit, a 415 for a disallowed content type, a 409 for a download of an object
that did not pass scanning, and a 503 when the scanner cannot reach a verdict (fail-closed).
An attachment coverage kind (one item per kind: attachment document, gated with
coverage.thresholds.attachment) is the slice-1 follow-up: it needs a declarative test target that
exercises the upload/download round-trip, which pulls the attachment runtime into the test harness —
a focused change kept out of the core slice. The TQL-ATTACH-34xx lint already makes the attachment
surface machine-checkable in the meantime.
Module layout
Section titled “Module layout”| Module | Adds |
|---|---|
tesseraql-core |
io.tesseraql.core.blob (BlobStore, BlobWriter, BlobRef, BlobSpec), FileBlobStore, AttachmentStore SPI, io.tesseraql.core.scan.AttachmentScanner + no-op default — all dependency-free (principle 5) |
tesseraql-yaml |
the kind: attachment model + parser, the object-storage/attachments/retention.attachments config, and the lint (TQL-YAML-12xx, TQL-SEC-411x) |
tesseraql-compiler |
buildAttachment — synthesizes the upload/download routes and binds the AttachmentStore |
tesseraql-operations |
JdbcAttachmentStore (managed tql_attachment), the off-heap upload/download integration with FileTransferService, and the RetentionSweeper attachments pass |
tesseraql-s3 (new) |
S3BlobStore + S3BlobStoreProvider (ServiceLoader), AWS SDK v2 (Apache-2.0) confined here, and S3/compatible config — added to the root pom.xml <modules> and the BOM. The BlobStoreProvider SPI + BlobStores factory live in tesseraql-yaml |
Integration tests for S3BlobStore run against Adobe S3Mock (Apache-2.0) via Testcontainers —
not MinIO; see the decision points. They are the compatibility gate that catches the checksum and
path-style issues above (consistent with the project’s Testcontainers IT approach for backing
stores).
Decision points
Section titled “Decision points”- Object-store SDK. AWS SDK for Java v2 (Apache-2.0), confined to the
tesseraql-s3leaf module, is the chosen client: standard, robust multipart/retry/presign, and it covers AWS and all S3-compatible stores from one module. The alternative — a JDK-only SigV4 signer overjava.net.http.HttpClient, in the spirit of the JDK-only OIDC/JWKS and mTLS choices — keeps the supply chain minimal but reimplements multipart, retries, and presigning. Deferred; theBlobStoreSPI is the seam that lets it replace AWS SDK v2 later without touching the DSL. - Test fixture / MinIO. MinIO is not used, even as a test fixture. The MinIO server is AGPLv3 (since 2021), and its community edition direction has soured (admin UI removed from the community build in 2025; widely described as in maintenance mode). Although using an unmodified AGPL server as a separate-process Testcontainers fixture does not propagate AGPL to TesseraQL, the project’s supply-chain-minimal ethos and AGPL-flagging license scanners make it cleaner to avoid it entirely. The S3-compatibility ITs use Adobe S3Mock (Apache-2.0); a gated CI job may run against real AWS S3 for highest fidelity. What TesseraQL ships is AWS SDK v2 (Apache-2.0) — no MinIO code is embedded or distributed.
- Metadata mode default.
managed(the frameworktql_attachmenttable) is the default, because attachment metadata is a new generic concern with no pre-existing app table;appmode is available per document for apps that want attachments in their own schema. Authorization flows through a 2-way SQLSELECTin both modes, so policy and Phase 29 scoping apply uniformly.
Delivery slices
Section titled “Delivery slices”Phase 30 ships in slices, each a reviewable PR with CI green:
- Attachment core — the
BlobStore/BlobWriter/BlobRefSPI andFileBlobStoredefault (tesseraql-core), theAttachmentStoreandAttachmentServiceSPIs with the managedtql_attachmentstore (tesseraql-operations), thekind: attachmentdocument and its synthesized off-heap upload/list/download routes (tesseraql-compiler/runtime), and theTQL-ATTACH-34xxlint. No S3, no new module — ships value on local storage alone. Delivered for managed mode; app-mode 2-way SQL metadata and theattachmentcoverage kind are the immediate slice-1 follow-up. tesseraql-s3leaf module (delivered) —S3BlobStoreon AWS SDK v2, contributed by aBlobStoreProviderdiscovered viaServiceLoaderand selected byprovider: s3; the deny-by-defaultallowedBucketsegress and SecretResolver credentials; theendpoint/region/pathStyle/checksumModecompatibility settings; theTQL-SEC-4110lint; and Adobe S3Mock compatibility ITs. Switchingprovider: s3is the whole change.- Scanning and retention (delivered) — the
AttachmentScannerscan-hook SPI with the no-op default, synchronous scan-on-upload, the download gate that refuses any non-clean object, and thetesseraql.attachments.scan.onInfected(quarantine/delete) policy; plus theRetentionSweeperattachments pass (age-based deletion of metadata and blobs, gated bytesseraql.retention.attachments). Completes the phase. Orphan GC is a later refinement (it needs aBlobStorelisting capability).
This completes the attachments leg of Milestone M9 (an approval-workflow application with org-scoped data and attachments).