Image upload-on-attach

T3 Code · 2026-08-09 · scoped from the Attach Anything plan (kbqqmizf5a1m) · images only, breaking change, no compatibility shims · implemented 2026-08-11; doc matches the build

The flow: attach → compress (existing ladder) → HTTP upload starts immediately → server writes pending-<uuid>.<ext>, returns the id → chip shows progress → send carries ids only → server renames pending-<uuid> to <thread-slug>-<uuid> → everything downstream is byte-for-byte what exists today.

One breaking contract change: the dataUrl upload variant is deleted, not deprecated. Out-of-date clients get a schema decode error on send. No dual paths.

Requirements → mechanism

RequirementHow it is met
Image onlyUpload endpoint accepts image/* mime only, rejects everything else with 415. Contract unchanged in shape: ChatImageAttachment stays the message-side type.
No backwards compatUploadChatImageAttachment (the dataUrl variant) is removed from the contract. Send-message decodes attachments as id references or fails validation. The Swift contract mirror is updated in the same PR so it fails loudly, not silently.
Upload starts on attachThe paste/drop/picker handler kicks off compress-then-upload immediately, before any user interaction. Up to 3 concurrent uploads; the rest queue.
Send blocked until uploadedSend is disabled while any chip is not ready. A failed chip blocks send until retried or removed, so nothing is silently dropped.
IdentifiersServer mints pending-<uuid> at upload, same grammar as today's attachment ids (<segment>-<uuid>). At send the Normalizer re-scopes it to the thread. The uuid never changes, only the segment.
Cancellable, deletableCancel mid-upload aborts the XHR; the server deletes the partial. Removing a ready chip calls DELETE /api/attachments/:id best-effort (pending ids only). A 30-day sweep of pending-* is the backstop for anything missed.
Loading stateChip state machine below. Progress is a percent number from XHR upload.onprogress, updated as text. No continuously repainting spinner (120 Hz rule).

Chip state machine

attached ──compress──▶ uploading (n%) ──▶ ready
                          │    ▲              │
                       cancel  │ retry        │ remove chip
                          │    │              ▼
                          ▼  failed      DELETE pending file
                       removed (partial deleted)

Server

1
Upload URL mint + POST /api/attachments/upload/<token> (as built) A ws RPC (attachments.createUploadUrl) mints the pending-<uuid> id and a signed, expiring upload URL, mirroring signed asset GET URLs. The browser POSTs raw bytes to it with no auth headers; the token carries authorization. This replaced route auth because route auth only exists for the primary environment, while signed URLs reach every environment the client can, exactly like asset fetches. The route rejects any Content-Length that differs from the minted byte count before reading, writes <id>.<ext>.part, then renames, so a partial write is never a valid attachment.
2
attachments.delete over ws (as built) Pending ids only; thread-scoped attachments are owned by messages and only die with their thread. Idempotent, and a delete aimed at an already-claimed uuid is a silent no-op rather than a theft of the thread's file.
3
Contract: send by reference Send-message attachments become { type: "image", id, name, mimeType, sizeBytes }, which is exactly the existing ChatImageAttachment. So the contract change is a deletion: the Upload variant and its 14M-char dataUrl schema go away. Swift mirror updated in the same PR.
4
Normalizer: validate and re-scope For each referenced id: resolve the file by uuid (prefix scan, accepting both pending- and thread-scoped names), verify metadata, rename to <thread-slug>-<uuid>.<ext> if still pending. Resolving by uuid makes send retry idempotent: if a send fails after the rename, the retry finds the already-renamed file and proceeds instead of erroring on a missing pending id. Missing file = validation error before anything persists.
5
Resolver and sweep resolveAttachmentPathById switches from the fixed-extension probe to the same uuid prefix scan (also serves asset URLs for pending files, which draft previews after reload need). Startup sweep deletes pending-* older than 30 days and *.part older than 1 day.

Nothing else server-side changes. Path lines, adapter inlining, the Claude attachments-dir grant, thread-deletion cleanup, and asset serving all operate on thread-scoped files exactly as they do now.

Client

6
Compression stays, and runs before upload The existing ladder is not (only) a transport optimization: it is what keeps images under the provider APIs' per-image caps that the adapters inline into. Compress on attach, upload the compressed bytes. This also means uploads are small and fast in the common case.
7
Upload targets the draft's environment The composer knows its environment (scoped refs); the upload posts to that environment's server, same base URL the asset fetches use.
8
Drafts and stash persist id + metadata, no bytes PersistedComposerImageAttachment drops dataUrl for { id, name, mimeType, sizeBytes, environmentId }. The localStorage quota dance (2.7M-char budgets, re-encode-for-stash, dropped-names bookkeeping for size) is deleted. After a reload, previews load through the existing signed asset URL flow. A draft with uploaded attachments now survives reload fully, File objects no longer matter after upload, which is a strict improvement over today.
9
Send gating Send button disabled while any chip is uploading or failed. The has-sendable-content check counts ready chips.

Edges, decided

EdgeDecision
Reload mid-upload The in-flight image is gone (its File object died with the page). The draft restores without it. Same information loss as today's crash-mid-encode stash path, and the existing "not saved before reload" messaging pattern covers it.
Send fails after rename Retry resolves by uuid and finds the renamed file (step 4). No orphaned-id dead end.
Draft moves to another environment Uploaded ids are server-local. If the File is still in memory, silently re-upload to the new environment. After a reload, the chip shows "not available in this environment" and must be removed or re-attached. Rare enough that anything fancier is over-building.
Stash restored in another environment Same as above: named "not available" chip. Stash stores environmentId per attachment to detect it.
Stash outlives the sweep A stashed ref older than 30 days restores as a named "expired" chip. No pinning machinery.
pending as a thread slug Reserved: toSafeThreadAttachmentSegment never returns pending (thread ids are uuids, but enforce it anyway with one guard so the invariant is explicit).
Cross-client id theft Any authenticated client can reference any pending id at send. Single-user product, all clients are the user's own; not a boundary worth building.
Out-of-date client Old client sends the dataUrl variant, server fails schema decode, client shows the send error. Old server + new client: 404 on the upload route, chip goes to failed with "server needs an update". Both loud, neither corrupts anything.
Duplicate attach of the same file Two uploads, two ids. No dedup. Not worth the bookkeeping.

Mobile: decided

Web-first, mobile fast-follow (Theo, 2026-08-09). This PR updates the Swift contract mirror so the old variant fails loudly at decode, but does not port the iOS upload client. Mobile photo attach errors until the follow-up lands; text-only mobile threads keep working. The follow-up is the iOS upload call plus chip states against the same two endpoints.

Not in scope

As-built notes

Verification