Uh oh!
There was an error while loading. Please reload this page.
fix(service-storage): tombstone the prior file when an attachment re-points file_id - #10241
Conversation
…points file_id
An UPDATE that changes a `sys_attachment` row's `file_id` detaches the prior
file exactly as deleting that row would, but `installAttachmentLifecycleHooks`
registered no update-side handler — so the old `sys_file` stayed at
`status='committed'` with zero join rows and no `deleted_at`.
That is outside the module's "fail toward retention" bias, which buys a LATER
look: `sys_file`'s declared lifecycle nominates a sweep candidate only via
`ttl { field: 'deleted_at' }` or `retention { onlyWhen: { status: 'pending' } }`,
and a silently detached file matches neither. The reap guard is never asked
about it, so the bytes are stranded permanently rather than reclaimed late.
The new `afterUpdate` handler reads the departed id from the engine-bound
pre-image `ctx.previous` rather than stashing across `beforeUpdate` the way
the delete pair does: since #5574 a predicate write dispatches one fresh
context per matched row per phase, so a stash survives the by-id path and is
lost on the predicate path. Measured on the wired engine both ways.
No revival leg: re-pointing a row onto a grace-window tombstone is already
resolved by the reap guard's sweep-time re-verification, which un-tombstones
and vetoes instead of reclaiming. A second mechanism would only drift.
The orphan rule itself is now one function shared by `afterDelete` and
`afterUpdate` instead of two copies.
Part of #10171
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx…terUpdate The wiring pin enumerates every hook the storage plugin registers on sys_attachment. #10171 adds the lifecycle module's `afterUpdate` detach leg, so the inventory grew by one — the pin is an inventory, not a pin on a branch that was removed. Part of #10171 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 5 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 6d6d58b5f89bf080117f1b35a42f6cef16a7c4a5 && git checkout 6d6d58b5f89bf080117f1b35a42f6cef16a7c4a5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 923c42470344b29a86f4bae37850e4305292c3ee aa76ba2f7243c7a8f8b5ca4f35ec2372609fc4b0 && git checkout -B drift-repro 923c42470344b29a86f4bae37850e4305292c3ee && git merge --no-ff aa76ba2f7243c7a8f8b5ca4f35ec2372609fc4b0
node scripts/docs-audit/affected-docs.mjs --json 923c42470344b29a86f4bae37850e4305292c3ee
|
Uh oh!
There was an error while loading. Please reload this page.
…e-fix guard The repo parses GitHub's closing-keyword grammar in three places and two of them disagreed about the optional colon. `duplicate-fix-guard.yml` accepted it, `cross-repo-issue-closer.yml` did not, so a merged PR written `Fixes: objectstack-ai/objectui#456` took the closer's exit path 1 -- the same quiet green a body with no cross-repo reference at all produces -- and the foreign issue was lost with no notice on the PR and no annotation anywhere. The guard's comment claimed the colon is GitHub syntax; that claim decided the direction of the fix and was verified rather than inherited. It is true, and the reference is in this repository: PR #10241 merged 2026-08-20T15:10:06Z carrying a `not fixed:` sentence bound to issue #10240, and that issue closed as `completed` at 15:10:08Z with its own closing-link summary naming #10241 alone. So the closer was under-matching and is widened; the guard was already right. Also adds the durable half. A shared module is unavailable -- neither workflow checks the repo out -- so `scripts/check-closing-keyword-parity.mjs` extracts all three parsers from the shipped bytes and asserts they agree behaviourally on the keyword set and the separator, while still refusing what each is scoped to refuse, and sweeps every tracked file for an unregistered fourth. The existing closer pin was blind to this: adding `:?` moved real behaviour and all 105 assertions stayed green, because no scenario had ever put a colon in front of a qualified reference. P1 now carries the spelling and M15 restores the defect. Fixes#9755
…it is NOT closing (H21) H7's header states the rule generally — GitHub's parser matches the keyword plus the number and ignores the surrounding prose, negations included — but its predicate is bound to a `Part of #N` declaration and fires only when the same `#N` carries both. A body that declares `Part of` for nothing is silent by construction, however plainly it says the card stays open. That gap cost a card. PR #10241 carried no `Part of` anywhere, and a sentence in its `## Out of scope` section bound a past-tense closing keyword directly to card #10240; that card closed `completed` two seconds after the merge and read as finished until a human reopened it a day later. (This commit message states the specimen that way on purpose: a commit message is a closing-keyword surface too, and it is NOT markdown, so backticks would not have protected it. Quoting the sentence verbatim here would have closed the same card a second time — the first draft of this message did exactly that, caught by running this predicate over it.) H21 flags a closing keyword bound to a `#N` the body never declared itself part of, when the SENTENCE containing it carries a negation or filing marker. Disjoint from H7 by construction: a number already declared `Part of` is H7's row and is skipped here. Stage-1 corpus measurement, which the commissioning ruling made the precondition to any blocking posture — the 300 most recently merged PR bodies (2026-08-19T15:13:23Z..2026-08-21T19:11:30Z, 2,564,259 body chars): sentence window (shipped) 1 / 301 keyword matches — the specimen. 0 FP. paragraph window 1 / 301 whole body before match 14 / 301 — 13 false positives anywhere in body 301 / 301 — the naive shape The 13 whole-body false positives are one PR (#10714) legitimately closing fourteen cards, so the sentence bound is load-bearing rather than stylistic. The result is not clean for lack of opportunity: the same corpus carries 116 sentences in the deliberate-non-closure register ("## Out of scope", "filed, not repaired here", "#N is not addressed here") and only one author bound a closing keyword to the number. Second corpus, same answer: 1,418 squash commit messages on main (2026-08-11..21), 228 keyword matches, 0 flags. Report-only, and deliberately NOT wired into the blocking gate: scripts/check-partof-closing-keyword.mjs imports h7PartOfWithClosingKeyword and fails PRs on it, so H21 is a separate function that gate does not import — widening the class must not silently widen a check that fails builds. The scheduled patrol already calls this sweep, so no workflow edit is needed. Part of #10392
Fixes#10171
An UPDATE that changes a
sys_attachmentrow'sfile_iddetaches the prior file exactly as deleting that row would, butinstallAttachmentLifecycleHooksregistered no update-side handler. The oldsys_filewas left atstatus='committed'with zero join rows and nodeleted_at.That is outside the module's stated "fail toward retention, not data loss" bias, which buys a later look.
sys_file's declared lifecycle nominates a sweep candidate only throughttl { field: 'deleted_at' }orretention { onlyWhen: { status: 'pending' } }, and a silently detached file matches neither — so it never becomes a candidate, the reap guard is never asked about it, and the bytes are stranded permanently rather than reclaimed late.The card's first question, answered by measurement
#10171 asked for two legs and flagged the second as "worth verifying against the reap guard's sweep-time re-verification — if that re-check resolves current references, only leg 1 stands." It does, so only leg 1 stands.
Re-pointing a join row onto a grace-window tombstone and then running the real
createSysFileReapGuardover that row:The guard resolves current references immediately before reclaiming, so the bytes were never at risk. No revival leg is added here — a second revival mechanism would be a duplicate answer to a question that already has one, and the failure mode of that is two implementations that drift apart, not a missing feature. The behaviour is pinned by the pre-existing case "vetoes and un-tombstones a row that regained references (sweep-time re-verification)" and now by a case driving it through the re-point path specifically.
Why
ctx.previous, and not thebeforeUpdatestash the issue suggested#10171 proposed
beforeUpdate/afterUpdatetwins of the delete pair, stashing prior ids on the hook context. Measured on the wired engine, that shape would have been silently half-dead:Since #5574 (ADR-0058 Addendum II D1/D2) a predicate write dispatches one context per matched row, and those row contexts are fresh objects built independently per phase (
dispatchPerRowBeforeHooks/buildPerRowAfterContexts), so a stash dies with the row that held it — exactly the multi-row updates that orphan the most files.The handler instead reads the engine-bound pre-image
ctx.previous.file_id, which is bound on both phases and both dispatch paths, and adds no driver round trip: the prior-row read is memoized per operation and already demanded on this object, becauseattachment-access-hooks.tsregisters abeforeUpdatehere and the engine asks that demand per object.The handler fires only when the payload actually carries
file_idand the value actually changes; with no pre-image it tombstones nothing, keeping the file. The orphan rule itself is now one function shared byafterDeleteandafterUpdaterather than two copies.Verification
Ablation — predicted signature stated before running: neutering only the
afterUpdatebody should redden exactly the 6 detach-positive cases while all 8 negative cases and, decisively, the wired delete-leg case stay green (a red delete case would mean the shared helper was hit instead of the update leg).Matched exactly; the delete-leg case stayed green. Restored byte-identically —
git hash-object8ecd3650e4dd572d7c23d6d690bb7c04e5d0e560before and after, with the mutation hashing24a4903c…in between.Rebuild, argued from the files rather than asserted. The mutated module is imported by the test as
from './attachment-lifecycle.js'— a relative specifier, which never resolves through packageexports, so vitest loadssrc/directly and nodist/of this package participates in either leg;ablation-dist-preflight.mjsdoes not apply to it. The wired cases' engine,@objectstack/objectql, is loaded fromdist/— it is a ledger-confirmed unaliased import for this package (KNOWN_UNALIASED_TEST_IMPORTS,scripts/check-test-source-alias.mjs) — and it was built at the start of this task and never mutated in either leg, so both legs ran against the same current dist. That is the half that could have produced a false-green ablation, and it did not: both wired cases reddened.Tests.
pnpm --filter @objectstack/service-storage test→Test Files 24 passed (24),Tests 397 passed (397). New cases cover both dispatch paths on the fake and, because a fake asserting its own model ofpreviouswould be circular, four cases driving realObjectQL.One pre-existing pin moved: the
sys_attachmenthook inventory instorage-service-plugin.test.tsgainedafterUpdate. That is an inventory that genuinely grew, not a pin on a removed branch.Gate union —
node scripts/pm/dispatch-gates.mjs(no paths passed) re-derived after the final commit, run on a clean worktree ataa76ba2f7, exit codes captured before any pipe, each quoted from the gate's own verdict line. All 17 families green; the notable ones:check:engine-double-contract—OK — 331 pinned, 133 in the DEBT ledger, 2 exempt.(no new pin, no baseline raised)check:where-matcher—✓ where-matcher conformance holds: 265 matcher(s) discovered, 265 answer the combinator battery correctly or refuse it loudly (160 refuse).check:type-check-debt—check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 298.4s, 1924 raw tsc error(s) total, none above its recorded number.check:type-check-coverage—OK — 64/77 workspace packages type-checked (plus the root), 13 in the DEBT ledgercheck:test-source-alias—OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/.check:nul-bytes—OK (scanned 6076 text file(s) ... no raw ASCII control bytes).check:i18n—OK (9 package(s) — all bundles in sync, no undeclared authoring keys).check:slot-lookup,check:query-options-erasure— bothratchet holds ... none newcheck:type-source-resolution,check:changeset-gate-self-tests,check:objectui-changeset,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-affected-docs— all greenNo ledger entry was raised in
check-type-check-coverage.mjsor anywhere else.Out of scope
Filed, not fixed: #10240 — the same leak through the delete verb.
beforeDelete→afterDeletehands ids over on the context stash, which the measurement above shows is lost on the predicate path, so amulti: truedelete of the last join row writes no tombstone either. Left alone deliberately: it is a different verb with a larger blast radius, and folding it in here would make this PR unreviewable as a single change. The divergence is named in a code comment rather than left implicit, so the two seams in this file are not silently disagreeing.#10091 is not addressed hereand#10170 remains open— neither file was touched.Generated by Claude Code