Skip to content

Private Document Assets Contract

easylogic edited this page Oct 10, 2026 · 8 revisions

Private document assets (#412)

Execution order and next-Issue selection: Storage delivery roadmap, maintained by Execute Storage under the 2026-10-09 human instruction.

Consumer preparation: full native, byte/hash and normal/refusal/partial-failure contract, including conditional reuse of existing #513 for supported Note native2 copies. This is preparation; actual consumer/private-runtime acceptance remains pending.

Implementation contract for Issue #412. The owned source is a local candidate based on a6151b1e6fbd55e0ebd743043495aab79415a8c6. Integration-owned migration, package export, bootstrap, and genuine PostgreSQL/OIDC/private S3 acceptance are pending. This page records the contract; it is not a release claim.

This contract adds immutable bytes to an existing document. It does not change native snapshots or local source files.

Admission and API

  • POST /v1/tenants/:tenantId/documents/:documentId/assets takes a raw body, x-asset-attempt-key, x-asset-kind (image or attachment), and Content-Type. No query, JSON body, filename, tenant owner, bucket, object key, or browser path is accepted.
  • image accepts only PNG, JPEG, WebP, or GIF when the declared type matches bounded file structure checks. PNG chunks include CRC checks; the other formats require a frame or image-data block. These checks do not decode pixels or prove a file is safe to render. Limit: 4 MiB, nonempty. SVG and HTML are refused.
  • attachment accepts only application/octet-stream. Limit: 8 MiB, nonempty. Its original bytes stay opaque. Download uses attachment disposition and nosniff.
  • GET /v1/tenants/:tenantId/documents/:documentId/assets/:assetId reads one ready asset through current document authority. The browser receives no bucket, object key, or storage credential.
  • x-asset-attempt-key is 1–120 ASCII characters, scoped to the current verified identity and tenant. The service binds its first use to document, kind, media type, byte count, and SHA256. Reuse with a changed request returns key_reuse.

Durable state and order

One document_assets row contains (tenant_id,id,document_id,identity_id,attempt_key,kind,media_type,byte_count,sha256,object_key,status,created_at,updated_at). status is pending, failed, or ready. The row has a composite FK to documents, a unique (tenant_id,identity_id,attempt_key), a server-generated UUID and key, and private tenant RLS. ready is never inferred from a successful PUT alone.

  1. Apply the existing DocumentStore.open authority, including Note native2 policy. Check verified current membership, writer role, and existing document in one tenant transaction. Insert the immutable pending attempt, or read the matching prior attempt. Commit before external I/O.
  2. Read the exact private key. If absent, conditionally write the original bytes. Read it back and compare byte length and SHA256. A conflicting stored object remains evidence and never becomes ready.
  3. Apply DocumentStore.open again. In a new transaction, check current membership, writer role, document, and the fixed row again. Commit ready only after the object comparison. Return the stored receipt. A response loss is recovered by the same attempt. failed records a known I/O or verification failure; uncertain outcomes may remain pending.
  4. A ready read applies DocumentStore.open, checks current membership and exact document binding, then reads and verifies the object again. Missing or corrupt bytes return unavailable, never success.

Database and object storage do not share a transaction. Failed or abandoned rows and objects remain for deliberate reconciliation. There is no automatic delete or retention job. No public bucket, direct browser URL, or durable download token is used.

Integration-owned migration and wiring

Dependency reservation — 2026-10-10: Integration/Root reserves the next 0018 prefix for the additive #412 private-document-assets migration and wiring, after its safe serial checkpoint. Storage candidate 1e6f4b9229cf8c871e52fbf8dff0a1bb2a83fb51 contains no additive migration source and its existing immutable contract requires no different migration ID. This is a reservation only; the final full ID/name, SQL, checksum and migrations.ts registration remain Root-owned and unimplemented here. Root's preceding #377 Workspace reservation is 0017_company_member_confirmation; existing 0006_company_member_admin and 0010_member_directory remain immutable. Static local c7 confirmed existing 0016_schema_registry at reservation time. Updated checkpoint: #503 is now CLOSED; consult actual current Integration serial work before #412 wiring. Private runtime ownership remains. No PG/S3/runtime or new asset source lease follows this record.

Migration document_assets must create the table above with byte_count BETWEEN 1 AND 8388608, sha256 ~ '^[0-9a-f]{64}$', kind IN ('image','attachment'), status IN ('pending','failed','ready'), a unique attempt key, composite document FK, private tenant RLS with FORCE ROW LEVEL SECURITY, owner/app policies, app SELECT,INSERT,UPDATE, and backup SELECT. The object key is an independent server-generated random UUID (assets/<opaque UUID>). The S3-compatible bucket must be private and support conditional PutObject (If-None-Match: *) and GetObject. A complete GET proves existence, length, and hash before ready. Wire one configured adapter, route registrar, and service package export. Do not broaden snapshot or global JSON limits. The asset POST sets only its own 8 MiB route limit.

The adapter is createS3CompatibleObjectStore({ endpoint, bucket, region, accessKeyId, secretAccessKey }). It uses SigV4 path-style requests and built-in fetch; no new SDK is required. Integration-owned bootstrap reads OFFICE_ASSET_S3_ENDPOINT, OFFICE_ASSET_S3_BUCKET, OFFICE_ASSET_S3_REGION, OFFICE_ASSET_S3_ACCESS_KEY_ID, and OFFICE_ASSET_S3_SECRET_ACCESS_KEY as one complete private configuration. HTTP is accepted only for a local loopback endpoint. A missing or partial configuration must not silently enable an asset route with public or in-memory bytes. The current adapter reads the complete object and verifies it against fixed metadata; it does not issue browser presigned URLs.

Real OIDC, PostgreSQL, private object-store, restart, and two-user checks are required for #412 closure. Controlled unit tests alone cannot establish them. Recovery #414 and native consumption #416 remain separate.

Clone this wiki locally