ezacto developers

Attachment storage

This is the physical persistence contract for ezacto-oss #122. It implements domain model §2.13, feature F4's static slice, D17, migration spec §4, and DV-16. Generated-report policy remains downstream of F2 + F6; HTTP upload/list/download belongs to the /api/v1 money-resource story; R2 and disk placement remain owned by D17.

Binary identity and logical metadata

file_objects is immutable content identity. content_hash is lowercase, unprefixed SHA-256 hex, which is the same representation emitted by the migration snapshot manifest. A hash identifies one file_key, byte size, and content type. An existing hash is reused only when all four values agree; conflicting metadata aborts the whole attachment write.

attachments is the logical record: it supplies the user-visible name, nullable uploader, and timestamps while referring to one file object. Multiple logical records may therefore preserve different names and owners for the same bytes.

Exactly one real owner

Ownership is represented by five real-FK tables:

The logical row carries five internal link guards, exactly one of which must equal the logical attachment id. Each guard is a deferred FK back to its matching join row. Matching triggers reject a join in any other table. The result is a commit-time invariant: zero owners, multiple owners, a missing parent, and an unmatched join are all rejected, including raw SQL and conflict-clause writes. There is no owner_type, generic owner_id, native receipts table, expense.receipt_id, or attachment placeholder on an owner table.

createAttachmentStore() executes file-object convergence, logical attachment creation, and the one owner join in one local transaction or one predetermined D1 batch. A failure rolls every new row back. Reads are owner-specific and always scope by both the real parent relation and (for content reads) the logical attachment id.

Object-store writes are necessarily outside that database transaction. Callers use idempotent content-addressed puts and must not delete the key to compensate for a metadata failure: another logical attachment may already share it. Unreferenced objects are safe to collect later with a storage-aware garbage-collection pass.

Static recurring policy v1

recurring_invoices.attachment_policy is nullable. Its only accepted v1 value is:

{
  "schema_version": 1,
  "type": "static",
  "attachment_ids": [101, 102]
}

The object is closed and versioned: unknown keys, duplicate keys, duplicate ids, non-integer ids, unknown versions, and empty lists fail. Every id must identify an attachment already owned by that same complete recurring definition. A referenced logical attachment cannot be removed until the policy is changed or cleared. generated_report and every other policy variant are deliberately rejected until the later F2 + F6 story owns generation semantics.

Harvest receipt mapping

The extractor's Harvest receipt vocabulary maps as follows:

Harvest/snapshot value Native value
downloaded SHA-256 file_objects.content_hash
receipts/<hash>.<ext> file_objects.file_key
verified downloaded bytes file_objects.byte_size
receipt.content_type file_objects.content_type
receipt.file_name attachments.name
source expense expense_attachments.expense_id

The downloaded byte count must equal Harvest's declared file_size before load. The sanitized golden fixture exercises that verification and round-trips the content identity through an expense-owned logical attachment.