Uh oh!
There was an error while loading. Please reload this page.
fix(storage): serve a tombstoned sys_file that still has a live holder - #11428
Conversation
A sys_file tombstone is recoverable state, not a delete: the reap guard
re-verifies references at sweep time, finds a regained holder, un-tombstones
the row and vetoes the reap. But the guard is the only thing that ever asked,
and sys_file's declared lifecycle (ttl { field: 'deleted_at', expireAfter:
'30d' }) nominates a tombstone only AFTER the window expires. Inside the
window the file was not a sweep candidate at all, while both download
endpoints refused anything not 'committed' — so a live attachment could point
at a file that 404s for up to 30 days and then silently starts working.
Ruled direction 2: the judgement moves to the read side, the revival mechanism
does not move at all. No lifecycle verb is added; nothing on the download path
writes to the row.
The two download handlers carried a copy each of `file.status !== 'committed'`.
They now share one predicate, isServableForDownload, which serves 'committed'
unconditionally, refuses 'pending' unconditionally, and asks
opts.resolveFileHolder about a tombstone. The plugin binds that to
findFileHolder — the reap guard's own definition of "is anything still holding
this file?", imported rather than re-derived — so the read verdict and the
sweep verdict are the same question answered by the same code.
Tests pin the pair, not the widening: tombstoned + a live holder serves 200 on
both endpoints while the guard vetoes and reclaims nothing; the last holder
removed 404s on both endpoints while the guard confirms the reap and deletes
the bytes. Boundaries pinned unmoved: pending, bare kernel, a throwing holder
question, a released field file, and the full authorization gate.
Repairs one prose claim this change falsifies: the platform-checklist item
attachments-storage.sys-file-status-pipeline stated the refusal as
"status != committed", which a runner would now score as a failure of the
fixed behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4…mbstone-download-live-join
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 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 7075a6c7e057ef61ff969efe56d219402b94166d && git checkout 7075a6c7e057ef61ff969efe56d219402b94166d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 68e8b4b53c387eb56d3c6ec4a976acf385eb977d 8c018711f747524a868fdb7a402d556b7e8df198 && git checkout -B drift-repro 68e8b4b53c387eb56d3c6ec4a976acf385eb977d && git merge --no-ff 8c018711f747524a868fdb7a402d556b7e8df198
node scripts/docs-audit/affected-docs.mjs --json 68e8b4b53c387eb56d3c6ec4a976acf385eb977d
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10246
Clause-②: yes— a currently-404 download becomes 200, so an endpoint's accept set widens. Draft, and stays draft;needs:contract-reviewis on the card and on this PR. This seat measuresclaude-opus-5, belowCONTRACT_REVIEW_TIER: it will not flip ready, will not enqueue, will not arm auto-merge, and will not clear the label.What was wrong
A
sys_filetombstone (status='deleted'+deleted_at) is recoverable state, not a delete. Re-pointing asys_attachmentjoin row onto a tombstoned file is byte-safe already: the reap guard re-verifies references at sweep time, finds the regained holder, un-tombstones the row and vetoes the reap.But the guard is the only thing that ever asked, and
sys_file's declared lifecycle (ttl { field: 'deleted_at', expireAfter: '30d' }) nominates a tombstone only after the window expires — candidates inside the window are[]. So inside the window nothing asked, while both download endpoints refused anything notcommitted. Net: a live attachment could point at a file that 404s for up to 30 days and then silently started working once a sweep finally ran.What changed — the ruled direction 2, and only that
Maintainer ruling of 2026-08-23 (recorded on the card), verbatim: 「10950 不考虑存量,其他接受你的建议」 — direction 2: the download path serves a tombstoned file while it still has ≥1 live join row; the tombstone stays and the sweep still reaps when the last join row goes.
Triage's constraint, kept verbatim because rewriting a ruling is rewriting it:
Accordingly:
file.status !== 'committed'. They now share one predicate,isServableForDownload, which servescommittedunconditionally, refusespendingunconditionally, and asksopts.resolveFileHolderabout a tombstone.resolveFileHoldertofindFileHolder— the single definition of "is anything still holding this file?", a deliberate union oversys_attachmentjoin rows and theref_*ownership columns, and the same function that decides whether the next sweep reaps the row. A read side that re-derived a narrower question (join rows only) would refuse files the sweep refuses to reap: the same defect, one limb over. Direction 1 was not taken, and no second reference-resolution implementation exists here.resolveFileHolderundefined and tombstones stay refused exactly as before. An unreadable holder question refuses, matching the direction the reap guard fails in.FILE_NOT_FOUNDcode is unchanged. Nothing in the tree pinned the old string (checked).Verification
All readings below were taken at
8c018711f7, which is this branch's head.The pair, not the widening. Proving only "tombstoned + live join ⇒ 200" would prove that something got wider, not that the boundary held. Both halves are asked of one shared fixture, on both download endpoints, with the sweep's verdict on the same row asserted alongside the download's:
Boundaries pinned unmoved:
pendingstill refused, bare kernel still refused, a throwing holder question refuses rather than falling open, a released (ref_*cleared) field file still refused, and the full authorization gate still applies to a served tombstone (AUTH_REQUIRED401 /ATTACHMENT_DOWNLOAD_DENIED403 — servability is not authorization).Suite.
pnpm --filter @objectstack/service-storage test→Test Files 26 passed (26)/Tests 435 passed (435), lock verdictcommand-exit 0. Dependency closure built first (pnpm --workspace-concurrency=2 --filter '@objectstack/service-storage^...' build, verdictcommand-exit 0).Ablation — signature predicted in writing before the run. Prediction recorded first: delete the holder limb from
isServableForDownloadand exactly 3 of the 9 tests fail — the two "serves a tombstone" cases and the authorization case (which never reaches the gate once the file 404s) — while the other 6, including the boundary half of the pair, stay green. Observed:Tests 3 failed | 6 passed (9), and the three failures were those three by name. A green ablation would have meant the mutation never reached the subject; the red is what proves it did, and it also answers the rebuild question empirically — the subject is imported by relative path, so vitest resolves it from source, notdist.The mutation was confirmed on disk (marker count 0 → 1) and the mutated predicate dumped from the file before the run. Restore ran under
trap restore EXIT INT TERMand is byte-identical:git hash-object=7270174267508d0632eca8c62073ab776e957f10, equal to the committed blob; marker count back to 0, anchor line back at 1. The restore leg re-ran green (435/435).(and|, so it matched nothing in either direction and its "0" was void rather than informative. It was re-done withgrep -Fon the literal, with a reverse control showing the pattern does find the line when present. The deletion direction is evidenced by the on-disk dump, the byte-identical restore, and the predicted-and-observed red.Gates. Derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no hand-supplied paths (7 paths vs merge base). Run to a real verdict, exit captured before any pipe:check:engine-double-contract(OK — the new double is pinned[update]and routes throughassertEngineUpdateDispatch),check:where-matcher,check:cross-package-test-inputs,check:i18n(service-storage in sync),check:route-envelope,check:test-source-alias,check:query-options-erasure,check:type-check-coverage,check:type-source-resolution,check:slot-lookup,check:entry-guard,check:parse-guard,check:published-files,check:doc-anchors,check:doc-authoring,check:role-word,check:docs-audit-scope,check:nul-bytes,check:plugin-teardown-shape,scripts/docs-audit/check-affected-docs.mjs, and the changeset gates (check-empty-changeset,check-changeset-no-major,check-adr-0087-registration,check-doc-frontmatter).check:type-check-debtcarries a margin-less ratchet for this package (@objectstack/service-storage: 51, its own note reads "THE MARGIN IS GONE"), so it was measured directly rather than assumed:tsc --noEmit --pretty false -p packages/services/service-storage/tsconfig.jsonreports 51 raw errors — exactly the ledgered number — with zero diagnostics in any of the three files this diff touches. The ratchet does not move.Declared narrowing. The repo-wide
pnpm lintwas not run locally; CI runs the farm exactly once regardless. The narrowed run is a measurement, not a skip, and all three pieces are here: (1) the linted population is read from ESLint's own config, which reported 4 of the 7 changed paths as "File ignored because no matching configuration was supplied" — so the governed set is the 3 TypeScript files; (2) counts come from--format json: 7 entries, 3 linted, 0 errors, 0 warnings; (3) invariance —eslint.config.mjsdeclares noparserOptions.projectand no typed@typescript-eslintrules, so this diff cannot move the verdict on any file it does not touch.Docs this change falsifies, repaired here
docs-drift is symbol-anchor precision-first and cannot see prose or table claims, so its green is not evidence. The prose corpus was swept by hand:
docs/qa/platform-checklist/areas/attachments-storage.json— clause 2 stated the download refusal as "status != committed". That was true when written and is not any more; a runner scoring it would have marked the fixed behaviour as a failure. It is re-keyed topending(the limb that genuinely still refuses), a new clause pins the widened one as a pair, and the revision history records the falsification rather than silently re-wording it.content/docs/permissions/attachments-access.mdx— states that a tombstone is recoverable state and that re-attaching makes the file downloadable immediately, with no sweep in between, while a file with no holder left still answersFILE_NOT_FOUND(404).Checked and found not to need repair:
content/docs/references/api/contract.mdxanderror-code-ledger.mdxlistFILE_NOT_FOUNDas a code name only, with no claim about when it fires; the download table inattachments-access.mdxenumerates only the 401/403 authorization refusals; no file underskills/mentions this surface at all, so there is noskills/**diff and no published-skill budget question.packages/spec/src/api/storage.zod.ts:55describes the route as resolving "a committed file to a short-lived signed URL", which after this change is narrower than the behaviour. This card carries zero ownership ofpackages/spec, so it is reported rather than edited — flagging the conflict instead of quietly picking a side.The five pages docs-drift named — read individually, verdict per page
docs-drift on this PR named 1 changed package, 7 documentable anchors and 5 hand-written pages. Each was read against the actual diff, asking one question: does this page state, enumerate or imply when a download 404s / when a tombstoned file is unreachable? None did, so none was edited — reasons per page, not in aggregate:
content/docs/api/client-sdk.mdx(anchorsgetDownloadUrl,storage.getDownloadUrl) — no repair. The onlygetDownloadUrloccurrence is a bare usage line (await client.storage.getDownloadUrl('file-123')); the page's error table lists the genericRESOURCE_NOT_FOUND, neverFILE_NOT_FOUND, and states no precondition on file status. Its other 404 talk is the historical never-mounted-route story, which this diff does not touch.content/docs/api/plugin-endpoints.mdx(anchors both routes + the sdk symbol) — no repair. The two rows describe what the routes do ("Resolve a short-lived signed download URL"; "Stable browser URL; 302s to the same signed URL"), and enumerate no refusal, nocommittedprecondition and no tombstone behaviour. The 404s discussed elsewhere on the page are the dispatcher 的 /storage/upload 用 upload(file, {request}) 调用契约里的 upload(key, data, options?) —— 对任何实现都会 TypeError #4087 unmounted-route history.content/docs/deployment/cli.mdx(anchor/files/:fileId) — no repair, and worth stating why it stays true: its "Released-file collection" row says a released field file "is tombstoned into the declared 30-day grace window; re-referencing the id within the window revives it". That describesclaimFile, which un-tombstones synchronously, and this PR adds no lifecycle verb and changes no revival, so the sentence is as true after as before. It makes no claim about downloads in the interim.content/docs/kernel/runtime-services/storage-service.mdx(anchorgetDownloadUrl) — no repair. The page documents the server-sideIStorageService, which is key-addressed and returnsBuffers; it carries an explicit callout thatgetDownloadUrl(fileId)belongs to the client accessor and is "a different shape from the server-sideIStorageServicedocumented on this page". Its "Typical Errors" list is adapter-level (ENOENTondownload(key)), with nosys_file.statusinvolvement. This diff changes the HTTP route's status predicate, not the adapter.content/docs/protocol/objectql/types.mdx(anchor/files/:fileId) — no repair. The page's onlyfiles/occurrence is"url": "https://cdn.example.com/files/abc123.jpg"inside a JSON example — an illustrative CDN URL, not this platform route. A substring false positive.⛔
content/docs/releases/v15.mdxwas flagged and deliberately not touched — release-owned and read-only; release notes are written centrally at release time. For the record, no factual error was found in it either: its line "tombstone on last detach (deleted_at, TTL 30d; pending uploads 7d), re-attach un-tombstones" stays accurate, because re-attach still un-tombstones and this change adds no lifecycle verb. Nothing to file.attachments-storage.jsonandattachments-access.mdxcame from an independent prose sweep, and neither page appears in the anchor list. A second independent sweep for sentences this change could falsify (only committed,must be committed,committed files are,FILE_NOT_FOUNDenumerations, tombstone/grace-window prose acrosscontent/,docs/andskills/) turned up nothing further beyond thepackages/specdocstring flagged above.Out-of-scope finding
Filed unassigned as #11427: record file-field hydration in
engine.tsstill drops any non-committedsys_fileto a bare id, so it and the download path now answer the tombstone question differently. Deliberately not folded in — hydration runs over many rows per read, so asking the holder question there is an N-queries-per-read design decision, not the same one-predicate change. That issue also records something triage may want:claimFileinfile-reference-lifecycle.tsalready un-tombstones a field file at re-point time, so the field-file surface already implements "revive at re-point time" — the shape declined for the download path. Not a contradiction of the ruling, but a fact the direction-1/direction-2 framing did not have in front of it.Note on this branch
The branch existed at
mainwith nothing pushed, the previous dev's session having ended. Their work was not lost — it survived as an unpushed local commit in the leftover worktree, and it is preserved here asaa7d0ce821(pushed first, before anything else, so a second session limit could not take it again) rather than rewritten. Every reading in this PR was taken independently against that code, not inherited from it; the merge commit brings the branch up to currentmain.Generated by Claude Code
Generated by Claude Code