fix(engine): ObjectRepository declares the findOne / update shapes it already published - #17255
fix(engine): ObjectRepository declares the findOne / update shapes it already published#17255claude[bot] wants to merge 3 commits into
ObjectRepository declares the findOne / update shapes it already published#17255Conversation
…lready publishes `IScopedObjectRepository.findOne` / `.update` declare `Record<string, any> | null` and `Record<string, any> | number | null`, and `IDataEngine` — the call each of these forwards to — declares the same. `ObjectRepository` sat between two narrow declarations and re-widened the value back to `Promise<any>` on the way out, which `implements IScopedObjectRepository` accepts (a wider return always satisfies a narrower one) while every call site reaching a repository through the CLASS kept reading `any`, `ObjectQL.createContext(…).object(n).findOne(…)` included. Census: one consumer, `engine-filter-alias.test.ts`, which read `.status` off a value that can be null. Repaired with the file's own `not.toBeNull()` / `!` idiom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…ared repository Compiler-driven probes (`ts.createProgram`, the idiom `packages/spec/src/contracts/scoped-context.test.ts` uses) over the exported class doors — `ScopedContext`, `ObjectQL.createContext`, `sudo()` — asserting the diagnostic NAMES the declared shape, so neither a bare "it errored" nor an `any` that erased the type can satisfy it. Anti-vacuity: the legal spelling must compile clean and no probe may report TS2307. Probes go through the CLASS, not `HookContext`: `HookContext.api` was narrowed to `IScopedContext` by #5945, so a `(ctx: HookContext)` probe is green on both sides of this fix and pins nothing. Measured, and recorded in the file header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Graded `patch`: nothing is widened and no symbol is added. The contract already published these shapes; the implementation is coming back to a declaration it had already published. Checked against the recorded WHICH LEVEL ruling of 2026-09-04 (decision batch #35, on #15294), whose `minor` trigger is additive widening. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 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 289e2da0651f870cdbb8556e19618cc37b14e1db && git checkout 289e2da0651f870cdbb8556e19618cc37b14e1db
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6058cb229a6a65f0cbec1084dd68f865b9bec269 ac5c48dbad1cb5529237fb6dcc40579688ef8617 && git checkout -B drift-repro 6058cb229a6a65f0cbec1084dd68f865b9bec269 && git merge --no-ff ac5c48dbad1cb5529237fb6dcc40579688ef8617
node scripts/docs-audit/affected-docs.mjs --json 6058cb229a6a65f0cbec1084dd68f865b9bec269
|
Part of #16786
Deliberately not
Fixes. The card carries two gaps and this PR lands one ofthem; the seat ruling on the card keeps the other —
IScopedObjectRepository.updateById,which lives in
packages/specand belongs to thedomain:specseat — as anunfixed remainder, so this PR must not close the card on merge. The half left
open is named under "What this deliberately does not do" below.
The defect
ObjectRepository.findOneand.updatedeclaredPromise[any].Both sit between two declarations that are already narrow:
IScopedObjectRepository— the contract this class carries animplementsclause for — declares
findOneas a record-or-nullandupdateas arecord-or-count-or-
null, and has since ruling A on [finding]ObjectQL.findOne/update/deletereturnhookContext.resultunder aPromise[any]declaration — nothing to guard, because nothing is declared #16231 landed (PR feat(engine)!:findOne,updateanddeletedeclare what they answer, and their hook seams are guarded (#16231) #16783).IDataEngine.findOne/.update— the call each of these two methods forwardsto, one line down — declare the same shapes.
The class received a narrow value and re-widened it back to
anyon the way out.implementsdoes not catch that: a wider declared return always satisfies anarrower one, so
class ObjectRepository implements IScopedObjectRepositorycompiled green the entire time while the members it published were
any.So the interface's narrowing reached only call sites whose static type is the
interface. The doors this package exports are typed as the class:
What I measured, and where it refines the card
Probes compiled against
packages/objectql/srconorigin/mainae19f5edb7,before any edit:
ctx: HookContext;ctx.api!.object(n).findOne(…)api: ScopedContext;api.object(n).findOne(…)any— 0 diagnosticsql.createContext({}).object(n).findOne(…)any— 0 diagnosticsIsAny[Awaited[ReturnType[…findOne]]]on the class doortrue— confirmsanycall
ctx.api.object(name).findOne(…)"does not resolve through the narrowedinterface". For a handler typed
(ctx: HookContext) => …that is not thecase:
HookContext.apiwas narrowed toIScopedContextby #5945 / #6311, sothat exact spelling reads the narrow type today and read it before this PR too.
The defect the card names is real; the door it named is not the one that was
wide. The
anylives on the class-typed doors above, which is where the fixand the pin both go. A probe written through
HookContextis green on both sidesof this change and would have pinned nothing.
Why
patch, and why this is auditableNothing is widened and no symbol is added.
packages/spec/src/contracts/scoped-context.tsalready publishes the narrower type; this is an implementation coming back to
the declaration it had already published, not a contract that moved. That is
the
patchrung.Checked against the recorded WHICH LEVEL maintainer ruling of 2026-09-04
(decision batch #35, on #15294, recorded in
.github/workflows/pr-automation.yml):it puts additive widening of a published surface — a new exported symbol, a new
accepted key or value — at
minor, and leaves "changes no public surface" atpatch. This PR adds no exported symbol and widens no accept set.For contrast, the sibling narrowing PR #16783 shipped
minorwith a BREAKINGbanner, and its own changeset gives the reason: "this PR also widens
@objectstack/objectql's index with new exported symbols, which that ruling putsat
minoron its own" — three new registered ADR-0112 error codes. This PR addsnone, so that driver is absent here.
The type-axis consequence is stated plainly in the changeset rather than hidden by
the grade: a consumer that typed against the concrete class (rather than the
contract, which already said this) and reads a field off
findOne's resultwithout a null check now gets the compiler asking for that check.
Census
The in-repo census for this change was one file:
engine-filter-alias.test.ts,reading
.statusoff a value that can benull. Repaired here with the file'sown
expect(...).not.toBeNull()/!idiom, which also makes the assertionnon-vacuous — under
Promise[any]that test's two spellings agreed trivially ifboth lookups returned
null.The test-typecheck debt ledger is unchanged at 44 files / 242 errors: nothing
was added to it.
The pin, and its ablation
packages/objectql/src/scoped-repository-return-narrowing.test.tsdrivests.createProgramover probe files — the idiompackages/spec/src/contracts/scoped-context.test.tsalready uses. Every negativeprobe asserts the diagnostic names the declared shape, so neither a bare "it
errored" nor an
anythat erased the type can satisfy it. Anti-vacuity: the legalspelling must compile clean, and no probe may report TS2307.
Reverse verification, run from the committed state, mutating
src/engine.tsbackto
Promise[any]on both members:No rebuild is involved in either leg: the probes resolve
../enginetosrc/engine.tsdirectly, so the mutation is visible to the compiler without adist/round trip.Verification
pnpm lint(full repo,eslint . --no-inline-config)pnpm --filter @objectstack/objectql typechecktsconfig.scripts.json+ test-layer ratchet)pnpm --filter @objectstack/objectql testscripts/pm/dispatch-gates.mjs --commands)pnpm check:dual-build-cjs-loadsPREREQUISITE NOT MET, exit 3; needs a full-repopnpm build. Its own words: "This is NOT a pass: nothing was measured." CI owns that build.pnpm check:type-check-debtPublished-surface evidence for the changeset (built, then grepped in the path
files[]actually ships):What this deliberately does not do
IScopedObjectRepository.updateByIdis untouched. It is still aPromise[any]inpackages/spec, and that surface belongs to thedomain:specseat. It stays open on [finding] Ruling A on #16231 narrows
IScopedObjectRepository, butctx.api.object(name)resolves through the CLASSObjectRepository— the hook-facing door keepsPromise<any>, andupdateByIdkeeps it too #16786 as the unfixed remainder — which is why this PRsays
Part of, not a closing keyword. Note the class side needs nothing here:ObjectRepository.updateByIdalready matches what the contract declares, sothere is no drift to repair on this side.
ScopedContext.object's return annotation is left asObjectRepository.The card's suggested shape (explicitly "not asserted") was to annotate it as
IScopedObjectRepository. I measured that route and did not take it: the classis deliberately wider than the contract in MEMBERSHIP —
create,delete,deleteById,aggregate,execute— and the class's own docblock records thatas intentional. Annotating the accessor removes those members from every
class-typed call site. Measured census for that route: 2 files / 8 errors,
six of them member-removal breaks (
Property 'delete' does not exist…,Property 'execute' does not exist…) inengine-filter-alias.test.tsandengine-repo-execute-elevation.test.ts. Repairing those would mean eithercasting around the fix or deleting live coverage. Narrowing the two drifting
members instead fixes strictly more doors (the class door, the exported
engine door, and a directly-held
ObjectRepository), removes nothing, andleaves the census at 1 file / 2 errors — both of which are the narrowing
working, not breakage.
packages/objectql/src/engine.tsin lines 11298–11801. This diff's hunk headers are
@@ -14710and@@ -14728—roughly 2,900 lines clear of the fenced region.
packages/spec/is touched. No governed surface is touched.🤖 Generated with Claude Code
https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Generated by Claude Code