Found while implementing #10091 (the sys_attachmentbeforeUpdate authorization guard). Filed rather than fixed: that card is about authorization; this is lifecycle bookkeeping in packages/services/service-storage/src/attachment-lifecycle.ts.
What is there and what is not
installAttachmentLifecycleHooks registers exactly three handlers, all delete/insert-side:
beforeDelete: stash the doomed rows' file_ids;afterDelete: tombstone any stashed attachments-scope committed sys_file whose last join row is gone (status: 'deleted', so the reap guard can reclaim the bytes);afterInsert: re-attaching a tombstoned file inside the grace window revives it.
There is no update-side handler. An UPDATE that changes a join row's file_id therefore:
- Orphans the old file silently — if that row was the file's last reference, no tombstone is ever written, so the reap guard never reclaims the bytes. Consistent with the module's stated bias ("fail toward retention, not data loss") but as a permanent leak, not a missed sweep: nothing later re-examines the file.
- Never revives the new file — re-pointing a row at a tombstoned (grace-window)
sys_file does not bring it back the way afterInsert would; the reap guard may then reclaim bytes a live join row references (worth verifying against the reap guard's sweep-time re-verification — if that re-check resolves current references, only leg 1 stands).
On today's origin/main any member can issue such an update (no beforeUpdate authorization at all); after #10091 the writers narrow to uploaders/parent editors, but an authorizedfile_id re-point still skips both legs.
Suggested shape
beforeUpdate/afterUpdate twins of the existing pair, firing only when the payload carries file_id: stash the prior file_ids before the write, run the afterDelete-style orphan check on them after it, and the afterInsert-style revival on the new value. Same best-effort posture (never block the user's write).
Backlinks: #10091 (where found), #2755 (the attachment kit's card, where the tombstone flow landed), #5180 (the delete-cascade design card for this object family — adjacent but a different mechanism: that one is about the PARENT record dying, this one about the join row being rewritten).
Generated by Claude Code
Found while implementing #10091 (the
sys_attachmentbeforeUpdateauthorization guard). Filed rather than fixed: that card is about authorization; this is lifecycle bookkeeping inpackages/services/service-storage/src/attachment-lifecycle.ts.What is there and what is not
installAttachmentLifecycleHooksregisters exactly three handlers, all delete/insert-side:beforeDelete: stash the doomed rows'file_ids;afterDelete: tombstone any stashedattachments-scope committedsys_filewhose last join row is gone (status: 'deleted', so the reap guard can reclaim the bytes);afterInsert: re-attaching a tombstoned file inside the grace window revives it.There is no update-side handler. An UPDATE that changes a join row's
file_idtherefore:sys_filedoes not bring it back the wayafterInsertwould; the reap guard may then reclaim bytes a live join row references (worth verifying against the reap guard's sweep-time re-verification — if that re-check resolves current references, only leg 1 stands).On today's
origin/mainany member can issue such an update (nobeforeUpdateauthorization at all); after #10091 the writers narrow to uploaders/parent editors, but an authorizedfile_idre-point still skips both legs.Suggested shape
beforeUpdate/afterUpdatetwins of the existing pair, firing only when the payload carriesfile_id: stash the priorfile_ids before the write, run the afterDelete-style orphan check on them after it, and the afterInsert-style revival on the new value. Same best-effort posture (never block the user's write).Backlinks: #10091 (where found), #2755 (the attachment kit's card, where the tombstone flow landed), #5180 (the delete-cascade design card for this object family — adjacent but a different mechanism: that one is about the PARENT record dying, this one about the join row being rewritten).
Generated by Claude Code