Repository navigation
feat(spec): declare IScopedObjectRepository.updateById's answer — the record or null, not any - #17836
Conversation
…contract `IScopedObjectRepository.updateById` declared `Promise<any>` while the engine door it forwards to declares `Promise<Record<string, any> | number | null>` and that door's by-id exit calls a driver member declaring `Promise<Record<string, unknown> | null>`. Narrow it to the by-id answer -- the written record, or `null` when the id matched nothing -- and pin the declaration with a compiler probe that reports NOTHING over the wide shape, so a revert reddens the suite instead of passing silently. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 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 0ea46a36a16cc4898b195b2edeb5bf75e51358c0 && git checkout 0ea46a36a16cc4898b195b2edeb5bf75e51358c0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 51b024a16c3878382e0eca508ee0c37d96e9fa81 d3344d52a79bf2ce40c6468a9966e963499d6bcb && git checkout -B drift-repro 51b024a16c3878382e0eca508ee0c37d96e9fa81 && git merge --no-ff d3344d52a79bf2ce40c6468a9966e963499d6bcb
node scripts/docs-audit/affected-docs.mjs --json 51b024a16c3878382e0eca508ee0c37d96e9fa81
|
Seat review of head
|
| # | check | reading |
|---|---|---|
| ① | at-tier verdict | not owed — Clause-②: no |
| ② | check-clause2-carriers --pair 17836 |
exit 0, both carriers agree, no widening tell |
| ③ | check-governed-merges --test over the three final paths |
exit 0 · ⭐ control with CLAUDE.md appended → exit 3 GOVERNED |
| ④ | every check, newest run per name | 32 names, 0 failing, 15 still running — landing waits on those |
Carrier absent on the PR and on card #16786, checked before any flip. ⛔ Not armed until ④ is complete.
Generated by Claude Code
Fixes #16786
Generic arguments are spelled with SQUARE brackets throughout this body —
Promise[T],Record[string, any]— so that no fragment of it is shaped like an HTML tag. The source carries the real spelling.What changed
One member of one interface, plus its docblock, a pin and a changeset:
PR #17255 landed this card's
objectqlhalf and left this member named as the remainder; triage re-routed the card todomain:specon 2026-09-10. This is that remainder and nothing else.Why — the measurement, ⛔ not an enumeration
find()narrowing extends to the sibling doors — and its enumeration namedscoped-context.ts:148/:164.updateByIdis a door of that family the enumeration missed. It is narrowed here because the measurement says the declaration was wider than every implementation and wider than the door it forwards to.Located by SYMBOL, re-derived at this branch's base
b59b74909c— ⛔ everypath:lineon the card is its writer's reading at their own head and two of them are days old.The declaration, and its siblings after PR #16783
origin/mainbefore this PRfindPromise[any[]]findOnePromise[Record[string, any] | null]— narrowed under ruling AcountPromise[number]insertPromise[any]updatePromise[Record[string, any] | number | null]— narrowed under ruling AupdateByIdPromise[any]← this PRdeletemember at all.scoped-context.test.tspins its ABSENCE (rejects-delete). Ruling A'sdeleteanchor is onIDataEngine, a different interface.What the implementations actually return
⭐ The whole defect class is that TypeScript's
implementsaccepts a wider declared return, so a class satisfies a narrowed interface while publishinganyto every caller. Shown at this member rather than asserted:packages/objectql/src/engine.ts,class ObjectRepository implements IScopedObjectRepository—async updateById(id, data): Promise[any]. Its own docblock, written by PR fix(engine):ObjectRepositorydeclares thefindOne/updateshapes it already published #17255, says why: "ItsPromise[any]is whatIScopedObjectRepository.updateByIditself declares, so the class matches its contract and there is no drift to repair on this side; that member ispackages/spec's to narrow and stays open on [finding] Ruling A on #16231 narrowsIScopedObjectRepository, butctx.api.object(name)resolves through the CLASSObjectRepository— the hook-facing door keepsPromise<any>, andupdateByIdkeeps it too #16786." ⇒ the class declaredanyto MATCH this member, not independently of it. Narrowing here is the half that was left open, not a second one.this.engine.update(name, { ...data, id }, { where: { id }, context })— theIDataEngine.updatedoor, which declaresPromise[Record[string, any] | number | null].driver.update(object, id, data, options), andIDataDriver.updatedeclares exactlyPromise[Record[string, unknown] | null].UnscopedHookApi(packages/objectql/src/hook-run-as.ts), never returns a repository at all —object()throws.scoped-context.test.ts,hook.test.ts) both answer a record.⇒
Promise[any]was wider than every implementation AND wider than the door it forwards to. The bar the dispatch set is met.Why the
numberlimb is NOT declared hereupdatecarries a count limb because it has two dispatch exits;updateByIdhas one. The implementation binds a truthy scalar payload id AND a pure-idwhere, and never declaresmulti, soresolveEngineUpdateDispatch(@objectstack/metadata-core) answersby-idfor every call this signature admits:unhonouredByIdPredicateKeys({ id })filters out the keyidand returns[], and the payload id equalswhere.idby construction, so neither refusal branch is reachable. A falsy id (0,'') is a REFUSAL rather than a narrower answer — it identifies no row, the dispatch rejects and the call throws.⇒ the honest answer is the by-id exit: the record, or
null.Consumer census
Who reaches
updateByIdthrough the interface-typed door, repo-wide (grepover all.ts/.tsx/.md/.mdx, excludingnode_modulesanddist):packages/spec/src/contracts/scoped-context.test.ts—legal-updateByIdcompile probe (discards the result) and a literalIScopedObjectRepositoryreturning({ id, ...data }). Both satisfy the narrowed shape unchanged.packages/spec/src/data/hook.test.ts— a fake returning({ id, ...data }). Same.packages/lint/**— twelve sites, all of which parse the SPELLINGctx.api.object(…).updateById(…)out of authored hook/action bodies as text. None reads a return type.content/docs/api/error-handling-server.mdx,content/docs/automation/hook-bodies.mdx— documented call sites; both discard the result.packages/objectql/src/engine-update-duplicate-record.test.ts— reaches it on the CLASS, whose own declaration this PR does not touch.⇒ no consumer relies on the⚠️ Out-of-repo TypeScript consumers DO break — that is the changeset's BREAKING banner, not an absence of breakage.
any, and nothing in the repository needed repair.pnpm --filter @objectstack/spec testandtypecheckare green with no consumer edits in the diff.The published surface — measured before and after, with controls
The emitted
.d.tsis what consumers resolve, ⛔ not the source. Built FIRST in both readings (npm pack --dry-run --jsonbefore a build readsdistas empty).The wide spelling now reads 0 anywhere under
dist/. Controls on both readings:IScopedObjectRepositoryreads 1 file (lit), a fabricated symbol reads 0. The chunk is re-exported by the published./contractsentry (dist/contracts/index.d.tsre-exportsi as IScopedObjectRepositoryfrom it).⭐ A second, unplanned control fell out of the ordering: the green pin leg ran while
dist/still held the OLD declaration and still asserted the NEW shape — direct proof the probe harness resolvessrc/, notdist/, so the ablation below needs no rebuild leg.The pin, and its ablation
packages/spec/src/contracts/scoped-context.test.tsgains a probe-based pin driven throughts.createProgram— the harness that file already owns. ⛔ Not@ts-expect-error: a directive is satisfied by ANY error on the next line, and the sibling pin's TS2578 mechanism is only read bycheck:test-typecheck, a gate OUTSIDE this suite. These fail in vitest itself.rejects-unnarrowed-updateById— assigning the answer into aRecord[string, any]slot must report TS2322, and the message must NAMERecord[string, any] | null. Naming the shape is deliberate: the file's own header records a phantom check that survived a revert because a code-only assertion was satisfied by a different type. It also asserts the message does NOT containnumber | null, so a copy-paste of theupdatesibling's wider shape is caught.legal-updateById-null-checked— the correct null-checked spelling must compile CLEAN. Anti-vacuity: a harness that resolved nothing would report an empty string for both probes.harness-self-test— the existing "can this harness report at all" control.nulla by-id miss resolves).GREEN:
pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/contracts/scoped-context.test.ts→Test Files 1 passed (1) · Tests 8 passed (8).ABLATION, run from the committed state (
d3344d52a7), mutating the declaration back toPromise[any]:⭐ The failure signature is the point: the probe reports NOTHING, and an absent diagnostic IS the
anyreading. The other seven legs stay green — only the leg that measures the narrowing moves, which is the anti-vacuity shape.The script carried
trap RESTORE_FN EXIT INT TERMwith an absolute path seeded fromgit rev-parse --show-toplevel, treated an emptygit hash-objectas FAILURE, and proved restore by blob comparison — ⛔ never by an exit code.Changeset —
minor+ BREAKING, and the grade was measured, ⛔ not inherited.changeset/16786-scoped-updatebyid-answer.md,"@objectstack/spec": minor, body opening BREAKING.Does the changed text reach a published
dist? Measured after a build, with controls:dist/analytics.zod-*.d.tsanddist/contracts/index.d.tsare both innpm pack --dry-run --json(2012 files).src/contracts/scoped-context.tsis NOT:files[]shipssrc/**/*.zod.tsonly, and this file is not a.zod.ts. Zerosrc/contracts/*paths ship.⇒ the declaration reaches consumers through
distand only throughdist. Not askip-changesetcase.Weighed against the precedent the dispatch named — PR #15280 (
SqlDriver.update()/ theTursoDriver.update()override), PR #14434 (@objectstack/driver-memory), and this card's own PR #17255, re-graded frompatchtominormid-round:⇒
minor+ BREAKING, and this case is STRICTLY stronger than the precedent. #17255'spatchargument was "the interface already declared the narrow shape; the class merely re-widened on the way out, so repairing it violates nothing new" — and even that was refuted. Here that argument is not available at all: the interface IS what moves, on a published exported type. There is no reading on which this is thepatchrung.ADR-0087 disposition:⚠️
not-required (no-migration-prescription).type-surface-only— the category built for exactly this class — was measured and is unavailable, ⛔ not skipped: its predicate 2 (no-spec-diff) is false by construction, because the narrowed symbol LIVES inpackages/spec; its predicate 3 (no-metadata-surface-diff) is false for the same file,packages/spec/src/contracts/**being an ADR-0087 shape surface by the gate's own classifier. Two of four predicates cannot hold for ANY edit to this symbol. The BREAKING token is carried rather than dropped — that erosion is what #13080 was filed about.Clause ②
Clause-②: no, as the claiming seat declared it. This round's own measurement agrees and does not re-declare: the change adds no schema key, no closed-set member, no published export and no registry entry —pnpm --filter @objectstack/spec run check:api-surfaceis green with no snapshot movement, becauseapi-surface/records that an export EXISTS, never what it resolves to. It narrows one existing member's declared return.Verification
pnpm --filter @objectstack/spec testandpnpm --filter @objectstack/spec typecheck— see the report on the card for the run figures.pnpm --filter '@objectstack/spec^...' build): EMPTY —packages/spechas no workspace dependencies (No projects matched the filters). Nothing to build; declared rather than silently skipped.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, 81 commands, every exit code captured BEFORE any pipe. 77 exit 0. Four exit 3 = PREREQUISITE NOT MET = NOT MEASURED, ⛔ never counted green:check:dual-build-cjs-loadsandcheck:type-check-debtneed a FULL-REPO build closure (87 packages / 31 dependencies unbuilt). CI owns that build; PR fix(engine):ObjectRepositorydeclares thefindOne/updateshapes it already published #17255 reported the same two the same way.check:lean-entry-closureand@objectstack/lint check:doc-formula-expressionsneed smaller closures, built and re-run in this round — see the report.#+digits and no closing stem appears in any commit message or trailer; swept per stem with a lit control that hitfixes/closes/part of/refsand four#-digit tokens.Out of scope, ⛔ not folded in
IScopedObjectRepository.findstill declaresPromise[any[]]andinsertstill declaresPromise[any]. ⛔ Deliberately untouched: neither is implicated by this measurement.find's width is ARGUED in the file's own module header (the corpus reads rows off it), andinsert's engine doorIDataEngine.insertitself declaresPromise[any], so there is nothing narrower to come back to. Narrowing either would be a NEW declaration with an unmeasured census — the class of change ruling A paid for..changeset/issue-N-slugspelling has zero precedents in this tree. Measured atb59b74909c: there are four (issue-17400-…,issue-17461-…,issue-17574-…,issue-17595-…). The numeric-prefix form the order prescribes is nonetheless the prevailing one (50 of 227) and is what this PR uses, so the instruction's conclusion stands and only its count was wrong.Generated by Claude Code