Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): hydrate a tombstoned sys_file that still has a live holder - #11844
Conversation
…lder #10246 stopped the two download endpoints treating a `sys_file` tombstone as the last word: they ask the reap guard's own `findFileHolder` and serve the row for as long as something still holds it. Record file-field hydration was not part of that ruling and kept the older `status === 'committed'` rule, so one `sys_file` row answered 200 at `GET /api/v1/storage/files/:id` and a bare id inside a record payload — which UI and export render as "no attachment". The predicate is not re-derived in the engine. "Still held" has ONE definition, `findFileHolder`, a union of `sys_attachment` join rows and the `ref_*` ownership columns, and it lives in a package objectql cannot depend on. So the engine declares `registerHeldFileResolver` and the storage plugin fills it — the same handover `resolveFileHolder` makes to the download routes. Batched, because hydration runs over many rows per read: `findHeldFiles` takes the whole tombstoned set, settles every row whose ownership columns answer it with zero queries, and issues at most ONE `$in` read for the rest. A read with no tombstone — every ordinary read — costs nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
`check:test-source-alias` — the agreement pin drives a REAL driver, and one read from dist/ would make it a verdict about build state rather than about the source beside it. objectql stays unaliased on purpose: it is a registered KNOWN_UNALIASED_TEST_IMPORTS entry, which is what lets an ablation of engine source, rebuilt, actually change what these tests observe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
…tchet `check:type-check-debt --re-measure` counts raw tsc errors per package against a shrink-only ledger; the new file added 6 (unused type import, the required packageId argument to registry.registerObject, and two untyped mocks whose calls[0][1] read as an empty tuple). Fixed rather than ledgered — the package re-measures at its frozen 51. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 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 9d772711bc1fa702fda9bceada0ceb66a6932460 && git checkout 9d772711bc1fa702fda9bceada0ceb66a6932460
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 50369dc07d7948793c5d3680bd2242610ae2cccd 29981de22cf8a627a7dc82d41541438ef0c99376 && git checkout -B drift-repro 50369dc07d7948793c5d3680bd2242610ae2cccd && git merge --no-ff 29981de22cf8a627a7dc82d41541438ef0c99376
node scripts/docs-audit/affected-docs.mjs --json 50369dc07d7948793c5d3680bd2242610ae2cccd
|
…ot just downloads The `sys_file` bullet scoped its "same question" claim to the download endpoints. That scoping was accurate only because record file-field hydration was NOT asking it — the divergence this branch fixes, written down as if it were the design, so a reader came away believing record reads do not participate. Widened to name both readers and to say why one predicate rather than two is what makes them agree. The boundary clause is kept and extended rather than softened: a file with no holder left still answers FILE_NOT_FOUND (404) and still keeps its bare id in a record payload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
`check:driver-memory-census` was red on this branch: the pin bound @objectstack/driver-memory by import and by devDependency, and the ledger covers neither. Migrated rather than ledgered. The two `ruled-permanent` entries exist because the in-memory driver's DISTINCT SEMANTICS are the subject — the schemaless arm of #4271, and the engine-fallback arm of #6468 where `supports = {}` puts the counter in the engine. This pin's subject is neither: it needs shared state across two read surfaces so the download verdict and the hydration verdict come from the same rows, and sqlite `:memory:` carries that identically. #5499 froze investment in the driver and #5704 made sqlite the test backend, so the ruled default already answers this — no ruling required, and no ledger row added by hand (the census still reports 2 ruled consumers, not 3). Checked before switching: every seeded key is declared, so there is no undeclared-field write for the SQL family to refuse, and nothing in the fixture is schemaless or leans on a driver-advertised capability. The vitest alias moves with the import; @objectstack/objectql stays ledgered-unaliased so an ablation of engine source, rebuilt, still changes what these tests observe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
os-sam
commented
Aug 24, 2026
Census red fixed by migration, not by a ledger row — and one self-flagged limit worth preserving here
What the failure was, and the route not taken
Migrated instead to the project's ruled test backend, ⭐ The implementer checked the disposition rather than taking my reading on trust: both existing ⭐ And it measured the carry before switching rather than assuming: every seeded key ( Ruled count is 2, not 3. The ablation was re-run, not quoted forwardThe implementer's reasoning, which is the correct instinct: "the backend swap changes the data path the fixture's rows travel, so the old reds were evidence about the in-memory driver's read, and reasoning that 'it must still follow' is exactly what an ablation exists to replace." Same four reds, same signatures, mutation confirmed on disk and in the consumed artifact ( ⭐ The limit the implementer flagged without being asked — recorded here so a reviewer sees it
I asked only whether the counter-direction control had gone green for the wrong reason. The answer is no — in the same read that leaves Two things still open on this PR, neither a blocker for review
Generated by Claude Code |
…ce widens Contract review rejected the changeset for disagreeing with the PR's own clause-② declaration. Verified at this head rather than relayed: the type `HeldFileResolver` is re-exported from the package entry (packages/objectql/src/index.ts), and `registerHeldFileResolver` is a public method on `ObjectQL`, which that entry exports too. Both are ADDITIVE — nothing removed, no existing caller's behaviour changed — which is a minor, not a patch. `@objectstack/service-storage` stays `patch` on purpose and is untouched: `findHeldFiles` is deliberately absent from that package's index, so its public surface does not move (positive control: the symbol resolves in 3 files on this branch, none of them the entry). Refs #11427. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11427
Clause-②: yes— see Contract-review assessment below. Draft on purpose; the dispatching seat lands it.Premise check first (dispatch constraint ①)
This card is hard-serial behind #10246 and re-parks if that predicate is not merged. Measured on
origin/mainbefore any edit:a41069ba37, an ancestor oforigin/main.isServableForDownloadatpackages/services/service-storage/src/storage-routes.ts:192, called by both download endpoints (:675,:722), wired to the reap guard's ownfindFileHolderinstorage-service-plugin.ts:422-425.packages/objectql/src/engine.ts:8082:if (row?.id != null && row.status === 'committed').The divergence is live. The card stands.
The defect
For one
sys_filerow in the residual state,GET /api/v1/storage/files/:idanswers 200 while a record read hydrating that same id answers a bare id — which UI and export render as "this record has no attachment". Two read surfaces, two answers about one row.Why this is not a mechanical widening (dispatch constraint ②)
Copying the download path's per-file
findFileHoldercall into hydration would be N queries per read, since hydration runs over many rows per read. It is not what this does.findFileHolderis a union of two limbs, and one of them is free:That is a pure column test on
sys_filerows the hydration pass has already fetched in its existing single batched read. SofindHeldFilestakes the whole tombstoned set, settles every row the ownership columns answer for at zero cost, and issues at most one$inread ofsys_attachmentfor whatever is left.Query shape, stated rather than asserted:
The whole block is gated on
tombstoned.length > 0, so the normal path does not even allocate. There is no performance fork to report: the batched union is +1 query worst case regardless of which layering choice is taken, so the design question below was decided on layering grounds, not cost.check:query-options-erasureandcheck:where-matcherboth stay green over the new$incall.Where the predicate lives, and why it is not re-derived
objectqldoes not depend onservice-storage(that dependency exists only in the other direction, as a devDependency), so the engine cannot importfindFileHolder. The engine already hard-codes one piece of storage semantics —row.status === 'committed'— and that copy drifting from the download path is precisely this bug. Adding a second copy would repeat the mechanism that produced it, against the explicit warning instorage-routes.ts("a read side that re-derived a narrower question would refuse files the sweep refuses to reap: the same defect, one limb over") andfindFileHolder's own docblock ("the only way to make 'the same' a property of the code rather than a claim in a comment is for both callers to run this function").So the engine declares the seam and the storage plugin fills it — the same handover
resolveFileHoldermakes to the download routes, and the same direction asregisterReapGuard:ObjectQL.registerHeldFileResolver(fn)/type HeldFileResolver(batched: takes the tombstoned rows, returns the ids still held)findHeldFilesinattachment-lifecycle.ts, besidefindFileHolderand sharinghasFieldReferenceOwnerstorage-service-plugin.ts, duck-typed so an older engine simply keeps tombstones un-hydratedUnwired (bare kernel, no storage plugin, tests that do not wire it) the behaviour is exactly what it was before this existed — symmetric with
resolveFileHolderabsent leaving tombstones refused.Scope is the residual population (dispatch constraint ③), measured
sys_attachmentjoin rows, not record file fields, so hydration never asks. Confirmed againstsystem-file.object.ts, whosedeleted_atdoc scopes tombstoning to "the lastsys_attachmentreference to an attachments-scope file".claimFile(file-reference-lifecycle.ts:475) patchesstatus: 'committed',deleted_at: nulland setsref_object/ref_id/ref_field, so a normally-claimed field file iscommittedbefore anything reads it.What remains is the reap guard's own named residual: hook races, direct-driver writes, and future trash restore. Nothing here re-opens the #10246 ruling — revival is still the sweep guard's alone and nothing on this path writes to the row; only the judgement moved.
What is pinned
packages/services/service-storage/src/tombstone-hydration-download-agreement.test.ts— the only place both halves can be asked, sinceobjectqlcannot importservice-storage. One realObjectQLengine over a real@objectstack/driver-memory, with the download routes and the record read driven off the same seeded rows, because a pair proved over two separate fakes proves nothing.ref_*, and held through a join row: download 200/302 and hydration enriched, asserted in the same test.FILE_NOT_FOUNDand hydration keeps the bare id. Without it, an implementation that hydrated everything would score green.pendingunchanged — only thedeletedlimb moved.findHeldFiles≡findFileHolderover a five-case matrix covering both limbs and both absences, plus "asks nothing when the columns settle it" and "asks once for a whole batch". The batched form existing is only safe while it is the same question; that is pinned, not asserted.All identities, never counts.
Reverse verification
Run from the committed state, so the restore is a real restore (
git checkout <branch> -- <path>), with atrap … EXIT INT TERMso a cap-kill mid-mutation cannot leave a mutated tree behind.The mutation is
origin/main'sengine.tsverbatim. Predicted in writing before the pin was written, and observed exactly:The two counter-direction controls stayed green through the ablation — the reds are the divergence itself, not a broken fixture.
Ablation validity, since this package resolves
@objectstack/objectqlthroughexportstodist/: every leg rebuilt, and the mutation confirmed in the artifact the suite consumes —node scripts/ablation-dist-preflight.mjs @objectstack/objectql 'registerHeldFileResolver' --absent→ "marker absent from all 8 built files", and after restore the same check without--absent→ "marker present in 6 built files".engine.tsalone leavesindex.tsre-exporting a now-missing type, so the DTS half failed. The JS the suite actually consumes was emitted and independently verified marker-absent, and the 4 reds are assertion failures inside a run where 440 tests passed, so the measurement stands. Onegrep -cline in the ablation script was mangled by shell quoting and printed agrep:error instead of its count; the mutation is confirmed by the other anchored observation (seam present (expect 0): 0) and by the dist preflight, so no measurement depends on the broken line.Contract-review assessment (Clause-②)
Path limb: no. Zero
packages/spec/**in the diff — no contract schema, no error-code ledger.Declaration limb: yes, on the content limb, declared rather than argued away:
@objectstack/objectql: a new exportedregisterHeldFileResolvermethod and a new exportedHeldFileResolvertype.That second bullet is sufficient on its own, so the honest call is
yes. No authorization behaviour changes: hydration does not authorize (byte-download authorization stays at the/files/:fileIdresolver, unchanged), and the file involved is one the reader's own record points at.findHeldFilesstays internal toservice-storage— it is not added to that package's public index, matching the existing note that keepsfindFileHolderinternal.Out of scope
⛔ #6116 — the fail-open
catcharound thesys_fileread in the same function — is untouched. The newcatchadded here is a separate, narrow one around the holder resolver, mirroringisServableForDownload's own, and it fails toward hiding: unreadable evidence is not evidence of a holder.Verification
Everything below was run in a dedicated worktree and, unless noted, on the final commit
c956e46e9f. Exit codes captured before any pipe; each gate is quoted by its own verdict line.pnpm --filter @objectstack/service-storage test@c956e46e9fTest Files 27 passed (27)·Tests 444 passed (444)pnpm --filter @objectstack/objectql test@2f0be8dda7Test Files 231 passed (231)·Tests 4106 passed (4106)pnpm --filter @objectstack/objectql typecheckturbo run build --filter=./packages/* --filter=./packages/*/*70 successful, 70 total@objectstack/objectql's suite is quoted at2f0be8dda7rather than the final commit: the only later change isservice-storage's own test file, so objectql's source anddist/are byte-identical between the two. The ratchet family was re-run on the final head after that push, which is the part that must not be quoted from an older tree.Gates derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no path args, clean tree, after the final commit — provenance line confirmsobjectstack-ai/objectstackat the checkout's ownorigin). All green:Also green, exit captured before any pipe:
check:nul-bytes,check:durability-log-level,check:query-options-erasure,check:cross-package-test-inputs,check:slot-lookup,check:type-source-resolution,check:stack-collection-maps,check:published-files,check:override-consistency,check:changeset-gate-self-tests,check:objectui-changeset,check-engine-split-ratio,check-plugin-teardown-shape,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-osv-exemptions,check-changeset-fixed.Two ratchets moved and were repaired rather than widened:
check:test-source-alias— the new pin imported@objectstack/driver-memoryunaliased. Fixed with an anchored alias in the package'svitest.config.ts, which is what the gate prescribes ("widening the registry entry is not the fix").@objectstack/objectqlstays ledgered-unaliased on purpose, and that is what makes the ablation above meaningful.check:type-check-debt --re-measure— the new test file pushedservice-storagefrom its frozen 51 raw tsc errors to 57. All 6 were mine and are fixed; the package re-measures at exactly 51. The ledger was not raised.Generated by Claude Code