Repository navigation
CONTRACT: feat(ui-server): admit generic chat files as durable server-owned originals - #3342
Conversation
…rver-owned originals (#3153) Add the neutral composer-upload route POST /api/drafts/:draftId/files. It streams one file into .brain-ui/attachments/<fileId>/, routes on the stored bytes (signature, strict UTF-8, then the shared track parser) and reserves room in a configurable host attachment pool (BRAIN_UI_ATTACHMENT_POOL_BYTES, default 1 GiB). It creates no inbox, queue or triage work. Drafts bind originals by fileIds, and a held send's files by one outgoing snapshot, in the operational database. Both survive a reload and a restart. chat_message.attachmentRefs sends them: the host resolves each against its own records and verifies the bytes on disk before any backend runs. It gives the agent read-only paths in a quoted <brain-attachments> block and never injects the contents. Acceptance writes the conversation's reference before it consumes the draft revision. GET /api/attachments/:fileId reads an original back after a digest check. Cleanup removes never-bound uploads after an hour. Formerly referenced originals go seven days after their last reference. Deletion is two-phase (mark, then unlink) and reconciled at boot. Governed workers mount the store read-only, and reads taint the session as chat_attachment. Additive contract change; documentation, decision records and a minor changeset are in this commit. Refs #3153 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
) Refs #3153 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regenerate the ui-sdk and ui-server API reports for the additive composer attachment exports, and document the server's new config field and message source argument in the package API component. Refs #3153 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#3153) The pool reservation is the declared Content-Length, so a longer body is refused before its extra bytes reach the disk uncharged. Add proof for the chat_attachment taint area and the governed read-only mount of the attachment store. Refs #3153 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Collision report at head
Detection only, not a lock. Whichever lands second re-runs |
…ventory (#3153) POST /api/drafts/:draftId/files and GET /api/attachments/:fileId were mounted by createApp but missing from the classified HTTP specification, so the real-app inventory test refused the tree. Both are supported (S) rows now, the declared-endpoint counts are 161, and the coverage matrix names each one's mounted-handler receipt: a real upload that stores the original read-only, and a typed 404 for an unknown file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…merging main The merge resolution took main's IngestionSource union, which adds user_marked and MarkedLookup, but did not re-add this PR's chat_attachment. Typecheck and the build refused the attachment classifier. Add it back, and regenerate the api reports, whose merged rows were duplicated or out of order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e sockets Since #3386, createApp starts a turn-journal idle maintenance pass after journal.ready, and that pass makes native manager calls in the background. In the merge of this PR with origin/main the pass was still in flight when the dirty-mount case opened six ready() sockets, so it counted seven: 3 of 3 runs failed, while origin/main and this PR's pre-merge head each passed. Await journal.maintain() first. That settles the startup pass, whose one keep-alive socket the six calls reuse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
No attachments are currently associated with this pull request. Maintained by uploads.sh · |
…split Epic #1342 split ws/dispatch.ts into ws/handlers/* and ws/run-session.ts into ws/session-run/*. The attachment admission and retry changes move to handlers/chat-message.ts and handlers/retry.ts; the run input, first-request, turn binding, retry request, message source and prompt changes move to session-run/{types,slot,turn,backend-entry}.ts. The chat_attachment taint source sits beside main's compute_output, the contract index keeps both sections, and the api-reports are regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's unused-export gate (#3448) arrived with the epic merge. hasRef had no caller; the others are used only inside their own module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Collisions at head |
|
Ready proof run 41773863563 at head |
|
Correction: the failed-job rerun of run 41773863563 is not merge evidence for this PR, whatever its result. #3550 reproduces on plain main, so it is a real failure, not one to rerun into green. This PR merges into its epic only on a fresh ready proof after the #3550 fix is on main and in |
fix - http-supported-mounts.test.ts takes main's #3550 join (ready, maintain, releasesSettled). It subsumes this PR's own maintain() await, which is dropped as redundant. - The contract index keeps both sections; http-api.md's inventory names the composer-file routes and counts 171 endpoints (168 at the merge base, plus this PR's 2, plus main's chat-deletion read). - governed-writable-workers.md cites requireWorkerBrainMount and verifyWorkerFolderMounts at their merged lines. - api-reports regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Collisions at head |
Refs #3153
Closes #3153 when the epic batch (
epic/3150-attachments) lands on main. This PR targets the epic branch, so GitHub will not close the issue on merge.What changed
The host now admits any composer file as a real, read-only original the agent can open. The new neutral route
POST /api/drafts/:draftId/filesstreams one file into.brain-ui/attachments/<fileId>/<name>. It creates no inbox, queue or triage work. It routes on the stored bytes (signature, then strict UTF-8, then the shared track parser), so a PDF, text, ordinary JSON or unknown binary is stored without any track rejection, while a valid GPX/KML/GeoJSON keeps the existing parser's evidence. Uploads reserve room in a configurable host pool (BRAIN_UI_ATTACHMENT_POOL_BYTES, default 1 GiB). Drafts bind originals withfileIds, and a held or unconfirmed send keeps its files in oneoutgoingsnapshot; both live in the operational database and survive reload and restart.chat_message.attachmentRefssends them. The host resolves every id against its own records (lineage, binding, state) and against the bytes on disk (size, SHA-256, no link) before any backend runs. It appends a quoted<brain-attachments>block with read-only paths and never injects the contents. Acceptance writes the conversation's reference before it consumes the draft revision.GET /api/attachments/:fileIdreads an original back after a digest check. Cleanup removes never-bound uploads after one hour and formerly referenced ones seven days after their last reference went, with a two-phase mark-then-unlink sweep and boot reconciliation. Governed workers see the store read-only, and reads taint the session aschat_attachment.This is #3153's slice of the approved design (
docs/plans/chat-attachments.md§4 and §6, plus the §7.1 block). Content reading (#3154) and the composer (#3155) are not in it.Why this shape
This follows the approved design and the maintainer rulings recorded in
docs/decisions/chat-attachments.md. It departs from the design in three places, each recorded under "What #3153 built" in that decision record:chat_message.attachmentRefsinstead of a newfilesvariant. Adding{ kind: "attachment" }tofileswould retypeChatFileAttachment[]for TypeScript readers; two ui-react tests reading.pathfailed to compile when I tried it. The contract counts a retype as breaking, so I used a separate optional field. It is additive and carries the same reference.outgoingis keyed by draft id (a draft has at most one snapshot) andmessageby session id. A draft id may contain:, which made the design's compound ids ambiguous.read_attachmentin the block text. That tool belongs to backends: read PDF and generic attachment contents with bounded fallbacks #3154 and is not registered yet, so the block says the agent may open the path with its file tools.Proof
Base
origin/epic/3150-attachments@4cd19855219c8bcdfafb8a1104161a90093c5796. Headb2c0cbcdd83eaab42e6d14973d50d071f95a2a92.New suites:
attachment-detect,attachment-routes(real composition root),attachment-ownership(real composition root and real WebSocket dispatch) andattachment-sweep. Every claim about an original reads the actual file at the path the agent was given and compares its SHA-256, for image, PDF, text and track fixtures.Failing-first witness.
attachment-routes"a PDF the track route refuses…":/api/track-uploadanswers422 unsupported_trackfor the Odysseus PDF, then the composer route stores it (201,kind: pdf, byte-identical on disk, mode 0444, nothing under.brain-ui/inbox).Mutations. Each was applied, the named test failed on the stated assertion, then restored:
[400, ATTACHMENT_INVALID], got[200, undefined]507 ATTACHMENT_CAPACITY, got201state = storedexpect(refused).toBeInstanceOf(DraftError)[409, false], got[200, true](altered bytes served)expect(h.starts).toHaveLength(0)(a backend ran)expect(h.starts).toHaveLength(0)[], got[fileId]read_onlyentry, gotundefinedG6_SOURCE_ReadThe cleanup protection uses two independent guards: rebinding clears the clock, and the mark requires no reference. Either one alone keeps a rebound file, so a single-guard mutation survives (checked for the reference re-check alone). Only the combined mutation fails.
Collisions (
bun scripts/collisions.ts --json --paths …): path collisions with #3305 (ws/dispatch.ts), #3316 (ui-sdk/src/protocol.ts) and #3335 (ws/run-session.ts), plus closure-only reports. Main also already has074_untrusted_marks.sql, so this migration is075_chat_attachments.sql;ingestion-taint.tschanged on main as well and will need a merge when the epic lands.Environment limits. This sandbox has no unprivileged user namespaces.
ui-backend-claude/ui-backend-piprovider-history, governed-worker's "/run mask refuses a host Unix socket" and the worker-boundary suites fail identically on the unchanged base checkout. The provider-history guard therefore needs the hosted proof. The turn path only appends a new block to new user messages; earlier messages, tools and system prompt are untouched.Ready-candidate run 41505209464 (head
b2c0cbcdd): diagnosis and fixNew head
1a060a04194d420f293e1007e3a4f6dfacd70383, base unchanged at4cd19855219c8bcdfafb8a1104161a90093c5796.unit (4): a real gap, fixed in1a060a041.real app method/path inventory equals the complete classified HTTP specificationreceived two mounted routes the spec does not list:POST /api/drafts/:draftId/filesandGET /api/attachments/:fileId. Both are nowSrows indocs/http-api.md, and the declared-endpoint prose counts are 161. Each has a coverage-matrix row indocs/http-api-coverage.md, a mounted-handler check inhttp-supported-mounts.test.tsand an explicit lifetime inhttp-mount-lifetimes.ts. The upload check is a real201that stores atextoriginal, verified inattachment_filesand as byte-identical on disk. The read-back check is a shared reader that answers a typed404 ATTACHMENT_NOT_FOUNDwithno-store. The two added check lines moved thecheckHandlercitation inhttp-api-coverage.md, now173-192.http-inventory.test.tsfailed on exactly+ "GET /api/attachments/:fileId"and+ "POST /api/drafts/:draftId/files". After the fix, the inventory, coverage-matrix, lifetime, HEAD and both new mount checks pass (6/6).bytesfrom 28 to 29 failsmounted supported handler: POST /api/drafts/:draftId/fileson"bytes": 28. ExpectingATTACHMENT_CHANGEDfailsimmutable mount readers > mounted supported handler: GET /api/attachments/:fileIdon"error": "ATTACHMENT_NOT_FOUND".ALLmiddleware and adds no other migration. The migration is now077_chat_attachments.sql: main took075(075_hygiene_media_operations.journal.sql, CONTRACT: feat: governed Delete, Move, Persist here and Keep a permanent copy for media findings, with journal capture, compensation and Undo (#2781) #3421) after main already had074_untrusted_marks.sql, and CONTRACT: feat(ui-server): bind typed chats to their creating owner so the owner can delete them #3456 onepic/3234-chat-deletiontakes076.unit (1): runner disk pressure (tests: unit shard intermittently fails subscription-status, turn-scope and answer-delivery liveness together #3256), not this change. All 8 failures in worker 1 come from the turn free-space floormax(5%, 2 GiB):TurnFixtureFreeSpaceError: 2,665,242,624 bytes available, 3,844,357,734.4 required. That is the(unnamed)failure.session-taintturn carries afree_space_floorstop receipt.subscription-statustests gotprovenBy/lastProvenAtnull, because the fixture turn was stopped before it succeeded.html-preview-runtimefailures followed its SIGKILL (exit 137) after worker 1 failed.subscription-status.test.tsfails the same tests at the same lines on both the PR head (:307,:321,:345,:363,:501,:539) and the unchanged epic base4cd198552. Both pass 32/32 without it. Evidence is on #3256. No change here; the fixture attribution belongs to tests: unit shard intermittently fails subscription-status, turn-scope and answer-delivery liveness together #3256 and tests: attribute low-space failures in remaining running-turn fixtures #2551.Local proof at
1a060a041:http-supported-mounts.test.tsalso has 9 failures that come from the host (/api/brain/*andPOST /api/schedules/proposals: unprivileged user namespaces are unavailable). They are unrelated to this diff and passed in the hostedunit (4).Collisions at
1a060a041(bun scripts/collisions.ts --json --pr 3342): textual with #3056, #3059, #3316, #3343, #3360 and #3371; closure-only with 23 PRs (#3054, #3069, #3116, #3292, #3293, #3295, #3309, #3317, #3320, #3325, #3335, #3336, #3341, #3345, #3352, #3362, #3365, #3366, #3367, #3368, #3369, #3370, #3372).Refresh on 2026-11-03 (head
5e8939bce)Merged
origin/mainat559fe7b41, which carries the merged blocker #3385.epic/3150-attachmentsat4cd198552was already contained. Conflicts and resolutions:drafts/store.ts: the list query readsfile_ids, outgoing_jsonand keeps main's retired-session filter, andsaveruns main'srefuseRetiredbefore this PR's file-aware fingerprint.IngestionSourceand the governancetaintenum have bothchat_attachmentand main'suser_marked/apify_result.ws/connection.tsimports bothCHAT_DELETION_CAPABILITYandCOMPOSER_FILES_CAPABILITY.schemas.tstakes main's hygiene imports plusattachmentFileSchema.integration-contract.md,package-api.mdandwire.mdkeep this PR's sections beside main's.http-api.mdlists the composer-file routes, counts 170 routes (main's 168 plus this PR's 2), and addschat_attachmentto main's taint sources.supported-inputs.mdaddsBRAIN_UI_ATTACHMENT_POOL_BYTESto main's list.Fixes after the merge:
66421db5fre-addschat_attachmenttoIngestionSource, which the merge had dropped; typecheck and build had failed on it.2b08bd8fbrenumbers the migration to077.5e8939bcefixes a test race.http-supported-mounts"a dirty failed mount discards its resources" failed 3 of 3 runs on the merge. It passed onorigin/mainand on the pre-merge head1a060a041. Main's CONTRACT: feat(ui-server): attribute admission to timed steps and prepare the turn journal while idle #3386 starts a journal idle pass at boot, and its native calls held a seventh socket while the case counted six. The test now awaitsjournal.maintain()first, and 3 of 3 runs pass.Refresh after the epic landed on main (2026-11-04)
epic/3150-attachmentslanded on main (#3523), so the epic tip is main @0760f7832f3d81f897f7925b417e2a53dd2465b8, which also carries epic #1342's turn-path split and epic #3103 (external compute). Mergef36aa2948brings it in; headd73637092.chat_messageadmission and envelope changes fromws/dispatch.tsnow live inws/handlers/chat-message.ts; the Retry changes inws/handlers/retry.ts. Fromws/run-session.ts: run input insession-run/types.ts, the first request andturn.attachmentFileIdsinsession-run/slot.ts, the retry request'sattachmentRefsinsession-run/turn.ts, message source and prompt (withChatFiles) insession-run/backend-entry.ts; queueing, native follow-up andacceptDraftfileIdsstay inrun-session.ts.chat_attachmentsits beside main'scompute_outputtaint source (type, governance event enum, HTTP doc). The contract index keeps both sections. api-reports regenerated withbun scripts/api-report.ts.hasRef(no caller) is deleted, the rest are module-local now, and so are the draft-store input interfaces.Refresh onto the #3550 fix (2026-11-04)
The failed-job rerun of run 41773863563 is not merge evidence.
epic/3150-attachmentswas fast-forwarded to mainb6ddb64241161a404a0aca2c13a52f7dbe1a2254, which contains the #3550 fix (#3552), #3557 and #3560. Merge88999ac36brings it in.http-supported-mounts.test.tstakes main's ui-server: dirty failed mount case intermittently counts a seventh native socket #3550 join (journal.ready,maintain(),releasesSettled()). This PR's ownmaintain()await (5e8939bce) is dropped: main's join already runsmaintain()and also waits for the release that held the seventh socket, so the await tested nothing of its own.worker-protection.ts, which merged cleanly. Its citations ingoverned-writable-workers.mdnow point at the merged lines:requireWorkerBrainMountat 379-388 andverifyWorkerFolderMountsat 391-432, both bodies unchanged.docs/http-api.mdkeeps main's inventory, adds the composer-file routes and counts 171 endpoints: 168 at the merge base, plus this PR's 2, plus main's chat-deletion read. The contract index keeps both sections, and the api-reports are regenerated.{"version":1,"kind":"brain-check-pr-receipt","head":"88999ac36d3eb1ab19b533eb382ac41673c13c31","base":"origin/epic/3150-attachments","mode":"focused","startedAt":"2026-11-04T12:37:16.359Z","finishedAt":"2026-11-04T12:38:29.163Z","checks":[{"name":"all lint and leakage gates","categories":["changeset"],"outcome":"passed","elapsedMs":10740.303833},{"name":"environment documentation","categories":["changeset"],"outcome":"passed","elapsedMs":93.04272499999934},{"name":"pending changeset package names","categories":["changeset"],"outcome":"passed","elapsedMs":82.87397699999929},{"name":"contribution changeset","categories":["changeset"],"outcome":"passed","elapsedMs":18.789601000000403},{"name":"affected fast invariants","categories":["verify","unit"],"outcome":"passed","elapsedMs":61851.899781}]}Checklist
docs/reflect the change, or nothing documented moved.docs/decisions/provider-history.md): the provider-history guard passes, or nothing on a turn's path changed. (Hosted proof pending; see environment limits.)Anything left open
releaseSession. That flow belongs to ui-react: design own-chat deletion and targeted cache retirement #3231. Until it is wired, a deleted chat's originals stay referenced and are never collected.session_infohas no message reference yet, so Retry with attachments is refused with "Attach it again". This is an honest refusal, not a silent drop.read_attachmentand the extractor are backends: read PDF and generic attachment contents with bounded fallbacks #3154.🤖 Generated with Claude Code