Repository navigation
fix(storage): a refused attach tombstones the uploader's never-attached file, outside the refused write's unit of work - #22542
Conversation
…ed file, outside the refused write's unit of work The attach gate hands the refused file_id to the lifecycle, which tombstones the caller's own committed attachments-scope upload when nothing holds it (findFileHolder) — the same tombstone a last-join-row removal writes, so the file follows the existing ttl and reap-guard path. The run is detached into a snapshot of the hook-installation context, so a caller's transaction rolling back cannot take the tombstone with it. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…rdict arrives Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…tead of erasing them Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…d and which was read Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 44 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f8beefb23ff6e71d0811bc279748df8865558db0 && git checkout f8beefb23ff6e71d0811bc279748df8865558db0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin faf6348508519197c6047b46fb30b6ae8910f6b2 714081a662888c14ee93af294eea65d24340cea5 && git checkout -B drift-repro faf6348508519197c6047b46fb30b6ae8910f6b2 && git merge --no-ff 714081a662888c14ee93af294eea65d24340cea5
node scripts/docs-audit/affected-docs.mjs --json faf6348508519197c6047b46fb30b6ae8910f6b2
|
…t gate, and name what it does not cover Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22466
Clause-②: no
Summary
When the attachment gate refuses a
sys_attachmentinsert, the refusedfile_idnow goes to the lifecycle. The lifecycle tombstones the caller's own never-attached upload with the same tombstone a last-join-row removal writes (status: 'deleted',deleted_at). From there the file follows the existing path: the 30-dayttl, then the reap guard, which re-checksfindFileHolderat sweep time. There is no new sweeper, no new lifecycle trigger, and no change tosys_file's lifecycle declaration. The tombstone runs outside the refused write's unit of work, so a caller's transaction rolling back does not undo it. This is direction (a) as triage ruled it (comment6080400758). It covers every refusal leg of the gate onmain, including the master-detail leg PR #22513 added (unlock6087255379).Files:
packages/services/service-storage/src/attachment-lifecycle.ts,packages/services/service-storage/src/attachment-access-hooks.ts, a new pin filepackages/services/service-storage/src/attachment-refused-attach-tombstone.test.ts, and apatchchangeset.Before: the reproduction on
origin/main5910b5eA real
ObjectQLengine over sqlite:memory:, with the storage lifecycle hooks and the attach gate installed asStorageServicePlugininstalls them. The calleru1uploadedf1: committed,attachmentsscope,owner_idu1.sys_fileafterdenyATTACHMENT_PARENT_ACCESScommitted,deleted_atnullATTACHMENT_PARENT_ACCESScommitted,deleted_atnulldenyATTACHMENT_PARENT_ACCESScommitted,deleted_atnullunresolvableATTACHMENT_PARENT_ACCESScommitted,deleted_atnullNothing later reaches the file. The tombstone hooks fire only when a file loses its last join row, and the declared lifecycle nominates only
deleted_at(ttl) orpending(retention) rows.Where the refusal throws, and where the tombstone is written
beforeInserthook, insideengine.insert's middleware chain. Every refusal leg funnels through onemayEditParentfalse, then oneforbid('ATTACHMENT_PARENT_ACCESS', …). That single site makes the one call to the new tombstoner, so no leg can drift. A rejection from either check (an outage, with its own503) never reaches that line, and it is not tombstoned.engine.insertopens no transaction of its own. A driverbeginTransactionspy counted 0 calls during a refused insert. The generic/datacreate door (createDatain the protocol, read from source) calls it without one. A system write made while the refusal unwinds lands and survives. Inside a caller-openedengine.transaction(anatomicbatch, an explicittransaction()), the same write joins the caller's transaction through the engine's ambient store (ADR-0034) and was rolled back. So mechanism assumption 2 ("the refusal throws inside the insert's transaction") is half false: the insert has none, and only a caller's own unit of work can roll the write back.createRefusedAttachTombstoneris created when the gate is installed (kernel:ready, outside every engine operation) and capturesAsyncLocalStorage.snapshot()there. Each refusal schedules its run inside that snapshot on a later event-loop turn. The engine's ambient transaction store holds nothing there, so the run reads and writes on its own connection after the refusal has left the operation. The refused write never awaits it. Awaiting an out-of-transaction query from inside an open single-connection transaction is the deadlock ADR-0034 describes. A detached one queues until the transaction releases the connection.writeTombstone,isLiveAttachmentsFile), shared bytombstoneOrphanedFiles(behaviour unchanged) and the refusal path. "Is anything still holding it" isfindFileHolder, the one definition the reap guard, the download path and the inventory already use.The conditions, read after the refusal
The file is tombstoned only when all of these hold:
attachments-scope andcommitted;findFileHolder: zero join rows and noref_*owner);isFileUploader, the upload ownership rule).Triage named the uploader
uploaded_by.sys_filehas no such column: its uploader isowner_id, which the upload doors stamp from the session. So that is the column the condition reads. The refusal does not wait on any of this, and the refusal envelope is byte-identical whether the file is the caller's, another user's, or unknown. The refusal discloses nothing about the file, not even through its timing (mechanism assumption 4).A client retry of the same
file_idinside the 30-day windowMeasured on
mainbefore the change: a tombstoned file that is attached again is revived by the existingafterInsertleg (committed,deleted_atnull). Pinned after the change:committed.deleted_atuntouched (run verdictkept: not a committed attachments-scope file).There is no silent half-state in either case.
Pins and ablations
The tombstone runs detached, so every pin waits on the one debug line each run ends on (its own verdict) rather than on a timer.
deny, a sharing non-verdict, master-detaildeny, master-detailunresolvable, degraded mode (no sharing service, unreadable parent): 403ATTACHMENT_PARENT_ACCESS, thentombstoned,status: 'deleted',deleted_atset, 0 join rowsLifecycleService.sweepover the realSystemFileschema (its declaredttl), clock at +31 days, realcreateSysFileReapGuard: row reaped, one byte delete of its key; an attached control file survivesbeginTransactionnot called; the tombstone landscommitted, 1 join row, no run scheduledref_*lineagekept: still held (field-owner),committedEach ablation ran through
scripts/ablation-replace.mjs(WRAP mode, the anchor must hit, the mutation is proven on disk), and the restore was proven blob-equal to HEAD with an emptygit diff HEAD:no run verdict for sys_file f1; warn lines: […failed to tombstone sys_file f1 after a refused attach (The database refused to run this query for object 'sys_file' …)]. The run inherited the caller's closed transaction.ref_*control goes redVerification
All on HEAD
0862db087(the branch withorigin/mainfaf634850merged), after rebuilding the@objectstack/service-storage...closure:pnpm --filter @objectstack/service-storage test: 48 files, 821 tests passed.pnpm --filter @objectstack/service-storage typecheck: exit 0, includingcheck:test-typecheck(the new pin file compiles undertsconfig.test.json).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 67 commands. All 67 ran with exit 0, and--ranreconciled67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN, a zero derived from the recorded exit codes. Also run:pnpm check:durability-log-levelandpnpm check:startup-registry-verdict, both exit 0.check:query-options-erasure: the pin file had 3as anyengine-option erasures (test surface 236 → 239). They are typed now, and the gate holds at 236.714081a66: the changeset text only, with no source or test change. All 67 derived families re-ran. 65 exited 0, including every changeset gate (check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:changeset-gate-self-tests,check:pm-changeset-deadline-census) andcheck-issue-citations(the new#22547reference resolves).check:dual-build-cjs-loadsandcheck:i18nanswered PREREQUISITE NOT MET (exit 3), so they are not measured on this head: the recreated worktree has only the@objectstack/service-storage...closure built. Both were green on0862db087, and neither reads a changeset.pnpm exec eslint --no-inline-config --format jsonover the 3 changed TS files reported 3 files, 0 errors, 0 warnings.eslint.config.mjssets no type-aware option (0 hits forprojectServiceorproject:), so this diff cannot move another file's lint verdict. The fullpnpm lintis CI's.Acceptance notes
main, and the degraded leg is pinned after the change. The create-grant leg (plugin-security's object CRUD check) is different. It refuses inside plugin-security's data middleware beforenext(), so the storage gate never runs and the file stayscommitted. A model in the same rig showed this: an outer middleware refusing first, the storage gate asked 0 times, 0 run verdicts, the filecommitted. No service-storage seam inside this card's file surface sees that refusal. See the out-of-lane findings; it is tracked in storage(attachments): an attach refused before the storage gate runs (PERMISSION_DENIEDcreate grant,FILES_DISABLED) still leaves the uploadedsys_filecommitted with no join row #22547.writeTombstoneand oneisLiveAttachmentsFilenow serve both triggers. Assumption 4 holds: everything is read under system context, nothing reaches the caller, and the refusal never waits on it.sys_attachmentupdate whose payload re-pointsfile_idat a fresh upload would leave that upload committed and unheld the same way. No producer writes such an update today (the console never updatesfile_id), so it is not covered or filed here. Carrier: none.tombstoneOrphanedFilesasks only join rows, not theref_*limb. That is benign: the sweep'sfindFileHolderre-check un-tombstones a field-owned file, and downloads and hydration ask the same question. It is unchanged here. Carrier: none.beforeInsert, a driver fault) is not this gate's refusal and is not covered. It belongs to the same family as the findings below.Out-of-lane findings (for the seat to file)
The seat filed this family as #22547 (
Blocked-by: #22466). How to cover it is triage's call on that card; nothing about it rides this PR.PERMISSION_DENIEDcreate grant,FILES_DISABLED) still leaves the uploadedsys_filecommitted with no join row #22547. The create-grant refusal (plugin-security CRUD check, 403PERMISSION_DENIED, raised in the middleware registered in itsstart(), beforenext()) and theenable.filesrefusal (plugin-auditenforceFilesCapability, 403FILES_DISABLED) both refuse an attach without the storage gate refusing. The uploaded file stays committed and unheld.sys_attachmentgrant: the console upload, then the final attach answered 403, leaving a committedsys_filewith no attachment. Today the console hides Upload without the create grant, so the remaining reach is a grant that changes between render and click.sys_attachmentinsert. That is either an outermost storage middleware (only aninit()-phase registration would precede plugin-security'sstart()-time one, and it would be the first such registration in the repo), or the refusing plugins, which are outside this card's file surface.Generated by Claude Code