feat(engine)!: findOne, update and delete declare what they answer, and their hook seams are guarded (#16231) - #16783
feat(engine)!: findOne, update and delete declare what they answer, and their hook seams are guarded (#16231)#16783os-musk wants to merge 11 commits into
findOne, update and delete declare what they answer, and their hook seams are guarded (#16231)#16783Conversation
…s measurement leg Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…heir hook seams Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…clare-engine-verb-results
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…codes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…prose Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…he off-contract driver doubles Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift CheckThis PR changes 5 package(s): 37 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 137 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 9ddf53fb5bc32abfab1699af2251573e7574d86c && git checkout 9ddf53fb5bc32abfab1699af2251573e7574d86c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5aef6db0f3c94a4789233b101825bd7da34c1f93 951369f510cd5231c93f6a18ede676fce016532f && git checkout -B drift-repro 5aef6db0f3c94a4789233b101825bd7da34c1f93 && git merge --no-ff 951369f510cd5231c93f6a18ede676fce016532f
node scripts/docs-audit/affected-docs.mjs --json 5aef6db0f3c94a4789233b101825bd7da34c1f93
|
Contract review (
|
| verb | origin/main |
head | driver exit(s) actually reached |
|---|---|---|---|
IDataEngine.findOne / ObjectQL.findOne / IScopedObjectRepository.findOne |
Promise<any> |
Promise<Record<string, any> | null> |
driver.findOne → Record<string, unknown> | null |
IDataEngine.update / ObjectQL.update / IScopedObjectRepository.update |
Promise<any> |
Promise<Record<string, any> | number | null> |
by-id driver.update → record-or-null; predicate driver.updateMany → count (only two result = assignments exist inside update(), engine.ts:11839 and :12047) |
IDataEngine.delete / ObjectQL.delete |
Promise<any> |
Promise<boolean | number> |
by-id driver.delete → boolean; predicate driver.deleteMany → count |
No throw arm was added to the declarations; the guards add one runtime arm each. "Hook seams are guarded" in code: verb-hook-result-shape.ts exports three typeof/Array.isArray container predicates (isFindOneResultShape, isUpdateResultShape, isDeleteResultShape — identity-free, so in-place mutation and reassignment to a legal shape stay legal; false/0/null are admitted where declared; undefined refused everywhere), and engine.ts calls each immediately after the after* dispatch and before maskSecretFields/stripSearchCompanion*/realtime publish (:9819, :12105, :13585), throwing an error class with code+status (500) — the same placement and envelope as #15823's find() guard.
Consumers (in-repo, non-test, head): packages/metadata/src/loaders/database-loader.ts (3 sites), packages/metadata-protocol/src/protocol.ts (4 by-id engine.update sites + updateData), packages/plugins/plugin-auth/src/objectql-adapter.ts (2), packages/runtime/src/sandbox/body-runner.ts (2), packages/objectql/src/lifecycle/lifecycle-service.ts (2), packages/objectql/src/validation/rule-validator.ts, packages/core/src/utils/{bulk-write,metadata-activation-store}.ts, packages/platform-objects/src/system/migration-flag.ts, packages/metadata-protocol/src/migrations/recorded-by-sentinel.ts, spec migration entries (prose examples), examples/app-showcase (3 ql.update calls, result discarded), examples/app-todo (its own engine shape on the action ctx, not IDataEngine). apps/* and examples/* are workspace members (pnpm-workspace.yaml) and were inside the turbo run typecheck census. Compiled types move only where a result is read without a null check or where the number limb reaches a record slot — the three non-test files the census names, plus 15 test files; I found no fourth. Runtime behaviour on the published doors: yes (Clause-②) — three new 500 refusal paths at the seams. Against every in-repo driver (memory results[0] || null, sql results[0] || null / formatOutput(...) || null / count > 0, mongodb ?? null / deletedCount > 0, turso ?? null / rowsAffected > 0) every exit is on-contract, so no in-repo caller's runtime output changes; the only measured runtime effects were the seven off-contract test doubles the PR repairs. The consumer repairs are no-ops on reachable paths (number limb unreachable from by-id calls) — verified against resolveEngineUpdateDispatch's use at :11252.
4. Changeset
.changeset/engine-verb-result-declarations.md: @objectstack/spec: minor, @objectstack/objectql: minor, @objectstack/metadata: patch, @objectstack/metadata-protocol: patch, @objectstack/plugin-auth: patch. **BREAKING** banner present; <!-- adr-0087: not-required (no-migration-prescription) … --> parses — check-adr-0087-registration on the head logs [BREAKING+bang] not-required (no-migration-prescription); the ! commit owes both and both are present. major correctly refused/absent. But the level axis is red — see F1. Type FROM/TO per door is stated; runtime FROM/TO is stated once for all three doors — see F3.
5. Tests
packages/objectql/src/engine-verb-hook-result-shape.test.ts: 29 cases (9/9/9 per verb + 2 vocabulary), counted independently; 15 refusal cases assert instanceof + code + status + event + object + observed via an outcomeOf capture (no bare toThrow()), 14 answerability/shaping cases exercise the real engine over an on-contract driver. Refusal cases redden if any guard is removed (consistent with the PR's 15/14 ablation; the 14 greens are held by the engine/driver, correctly). The { deleted: 1 } envelope has its own pin; the sanitizeRowError SQL-verb constraint is pinned. ErrorCode.safeParse control rejects a near-miss spelling. No it.skip/.only/.todo/xit in any changed test file (scanned all 17). Typecheck coverage: tsconfig.test.json includes src/**/*, typecheck chains check:test-typecheck; CI Type Check · workspace / debt ledger green. The seven double repairs (data-engine.test.ts, engine.test.ts, plugin.integration.test.ts, engine-filter-tokens.test.ts, seed-loader.test.ts) are as described. Gap: the declarations themselves have no pin — see F2.
6. ADR-0112 / vocabulary ledger
FIND_ONE_HOOK_RESULT_NOT_RECORD, UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE, DELETE_HOOK_RESULT_NOT_WRITE_SHAPE are registered in ERROR_CODE_LEDGER (error-code-ledger.zod.ts) with owner/meaning comments, carry readonly status = 500 (what check:error-status-conformance derives from), and the generated contract.mdx / error-code-ledger.mdx are regenerated (+3). The registration footprint is byte-for-byte the precedent's: the six non-test files naming FIND_HOOK_RESULT_NOT_ARRAY on main are exactly the six naming the new codes on the head. ADR-0130 in this repo is the release-artifact co-ownership boundary and has no interplay with these codes; the runtime vocabulary these must be in is ADR-0112 D3/D4's ledger, and they are.
7. CI on head c5935b2445
38 check runs: failure 2 (Check Changeset, both workflow runs — same cause, F1), success 30, skipped 6 (Auto Label/Check PR Size duplicates, Packed-tarball smoke (opt-in) ×2, Console Pin Gate), in_progress 0. mergeable_state: unstable. Head is 17 commits behind origin/main (dd2184ae), merge-base 8ccf7a1d.
Findings
F1 — BLOCKING. Check Changeset is red on the level axis. Log, verbatim: ⛔ This PR declares clause-② YES and grades a package it grew patch. naming @objectstack/metadata: patch and @objectstack/metadata-protocol: patch. @objectstack/plugin-auth: patch is the same case and escapes only through the gate's packages/*/src/** blind spot the PR body itself cites (#16713). The PR body's "111 derived, 111 run, all exit 0" is not a reading of this axis: run locally, check-changeset-no-major has no $GITHUB_EVENT_PATH, reads no declaration, and reports NOT MEASURED with exit 0. Expectation: raise the three consumer entries to minor (the fixed group versions in lockstep, so the published version outcome is identical) — or, if the seat rules the declaration wrong for consumer repairs, have that ruled rather than tolerated in the gate; and correct the body's gate claim to say the level axis was NOT MEASURED locally.
F2 — The three declarations have no pin; only the guards do. Reverting findOne/update/delete to Promise<any> while keeping the guards reddens nothing: every census repair (if (!row), typeof x === 'number' ? … : …, result!.assignee) and the one new typed helper (writtenRow(result: Record<string, any> | number | null)) compiles identically against any. Ruling A's declaration half is therefore unenforced (ADR-0049). Expectation: a type-level pin under check:test-typecheck in packages/spec/src/contracts/data-engine.test.ts — e.g. // @ts-expect-error on an un-null-checked property read of findOne's result and on delete's result used as a record, and a number-limb narrowing that fails to compile if update widens back to any.
F3 — Changeset runtime FROM/TO is aggregated, not per door. The type FROM/TO is per verb, but the runtime change ("a host that installs after* handlers … now receives a refusal") is one sentence for three doors, and omits the second source the PR's own developerMessage names: an off-contract driver answer now trips the refusal too. Expectation: three FROM/TO lines (per door: FROM whatever the dispatch left, returned silently → TO 500 <CODE>), each naming both sources.
F4 — byIdUpdateRecord (metadata-protocol/src/protocol.ts) throws a bare Error with no ADR-0112 code on the number limb. Unreachable by construction today, so acceptable as an invariant assertion; note that if it ever fires at an HTTP door it demotes to a generic 500 with no branchable code.
F5 — Observation, no change owed. The update guard checks the batch hookContext after the per-row afterUpdate fan-out; per-row contexts are separate objects (buildPerRowAfterContexts), so a per-row handler's ctx.result reassignment never reaches the seam. That matches the ruling (the batch context is what the call returns) but the engine.ts:12117 comment "a per-row handler can reassign it" overstates it, and no pin covers the per-row path.
F6 — Reported gap is real; needs a card number before landing. ScopedContext.object(name): ObjectRepository (engine.ts:14728) returns the class, whose five verbs still declare Promise<any> (:14571–:14612), so the hook author's door ctx.api.object(x).findOne() is not narrowed. The PR reports it and the PM seat said it will file; no card is referenced yet on the PR or #16231. Expectation: the follow-up card numbers (this and the type-surface-only gate defect) appear on the PR before it leaves draft.
F7 — 17 commits behind main; mergeable_state: unstable. Bring the branch up to date after F1 so the gate re-reads on a current merge-base.
Seat boundaries: no approval/review action, no label, title, body or branch change, no merge, no file edited; throwaway review refs deleted.
Generated by Claude Code
…ause-② declaration requires `Check Changeset`'s level axis is red on this PR: it declares `Clause-②: yes` and grades three packages whose `src/**` the diff moves at `patch`. A purely additive widening of a published package's public surface takes at least `minor` (maintainer ruling 2026-09-04, decision batch #35, on #15294). `@objectstack/metadata` and `@objectstack/metadata-protocol` are the two the gate can name. `@objectstack/plugin-auth` rises for the same reason and is NOT graded by the gate: `PUBLISHED_SOURCE_PATH` is anchored `^packages/([^/]+)/src/` and this package's changed source is `packages/plugins/plugin-auth/src/` — one directory level deeper, so it never enters the gate's "grown" set. That is the blind spot carded as #16713. The level floor comes from the act the PR declares, not from what the instrument happens to measure. No source, test or config byte moves; the level axis is the only thing this commit answers for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…seam guards Ruling A has two halves — the declarations and the seam guards — and only the guard half was pinned. Reverting `findOne` / `update` / `delete` to `Promise<any>` while keeping the guards reddened nothing: every consumer repair the census produced compiles identically against `any`, so those repairs record that a narrowing once happened, not that it still holds. That is ADR-0049's enforce-or-remove target. Three `@ts-expect-error` cases under `check:test-typecheck` close it, on the mechanism the neighbouring #12248 block already relies on: each directive is resolved by tsc today, so a widening back to `Promise<any>` leaves it UNUSED, which is itself an error in a file whose debt ledger is exact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…urces The type FROM/TO was already per verb; the runtime half was one sentence for three doors and named only the handler. The refusals' own `developerMessage` names TWO sources — an `after*` handler that assigned an off-declaration value, and a driver whose exit answered off `IDataDriver` — and the second one is the source the seven test-double repairs in this PR actually came from, which is why the refusal sentence names the seam instead of accusing the handler. Three per-door lines now carry FROM (what the dispatch left, returned silently, and who read it first) to TO (the registered 500 code raised at that seam). Driver limbs cited are read off `packages/spec/src/contracts/data-driver.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Contract review (
|
| surface | declared | evidence |
|---|---|---|
IDataEngine.findOne / update / delete |
Promise<Record<string, any> | null> / Promise<Record<string, any> | number | null> / Promise<boolean | number> |
packages/spec/src/contracts/data-engine.ts:276, :278, :279 |
IScopedObjectRepository.findOne / update |
same two | packages/spec/src/contracts/scoped-context.ts:149, :165 (the ruling's :148/164 shifted by one line of docblock) |
ObjectQL.findOne / update / delete |
same three | packages/objectql/src/engine.ts:9713, :10900, :13140 |
| seam guards | isFindOneResultShape / isUpdateResultShape / isDeleteResultShape immediately after the after* dispatch, before maskSecretFields / stripSearchCompanion* / realtime publish, ahead of return hookContext.result at :9847 / :12188 / :13638 |
engine.ts:9832, :12119, :13592 |
| error classes / codes | FindOneHookResultNotRecordError → FIND_ONE_HOOK_RESULT_NOT_RECORD, UpdateHookResultNotWriteShapeError → UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE, DeleteHookResultNotWriteShapeError → DELETE_HOOK_RESULT_NOT_WRITE_SHAPE, all readonly status = 500 |
verb-hook-result-shape.ts:134-157, :193-220, :261-285; exported from packages/objectql/src/index.ts:209-222 |
| ledger (#16404) | three rows with owner/meaning comments | packages/spec/src/api/error-code-ledger.zod.ts +FIND_ONE_HOOK_RESULT_NOT_RECORD, +DELETE_HOOK_RESULT_NOT_WRITE_SHAPE, +UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE |
check:dispatcher-error-vocabulary: runs in Lint & Repo Gates (.github/workflows/lint.yml:2610), green on the head. The runtime declaration table (packages/runtime/src/dispatcher-error-vocabulary.ts) carries neither the new codes nor the sibling FIND_HOOK_RESULT_NOT_ARRAY — consistent: a ledger row removes the site from the scan ("as any registration does", script header), so there is no stale pending-registration row to leave behind.
Refusal wording: all three developerMessages name the seam and both sources — verbatim, findOne: "TWO things can put another shape here: a '${event}' handler that assigned one, or a driver whose 'findOne' answered off its own contract" (verb-hook-result-shape.ts:173-184); update names 'update' / 'updateMany' (:238-250); delete names 'delete' / 'deleteMany' (:303-315). The shared user-facing sentence composer states the reason in its own docblock (:336+). Correct for the reason the PR gives: four of the seven repaired doubles were driver exits, not handlers.
Census spot-checks (2 of 4 doubles): packages/spec/src/contracts/data-engine.test.ts — five delete doubles { deleted: 1 } / ({}) → 1 / true; packages/runtime/src/seed-loader.test.ts:73-79 — { deleted: 1 } → 1. The { deleted: 1 } envelope has its own refusal pin at engine-verb-hook-result-shape.test.ts:385-392 and sits in the refusal table at :362. The new test file has 22 it( cases, no .skip / .only / .todo.
Changeset (.changeset/engine-verb-result-declarations.md @ head): @objectstack/spec: minor, @objectstack/objectql: minor, @objectstack/metadata: minor, @objectstack/metadata-protocol: minor, @objectstack/plugin-auth: minor; **BREAKING** banner; <!-- adr-0087: not-required (no-migration-prescription) … -->. Check Changeset log on the head, verbatim: ✓ check-adr-0087-registration: 1 declared-breaking changeset(s) … [BREAKING+bang] not-required (no-migration-prescription); ✓ This diff introduces no major bump.; ✓ LEVEL AXIS: this PR declares clause-② yes, and no package whose packages/*/src/** it moves is graded patch. with carrier: needs:contract-review IS on this PR. As the dev's report states, that green does not grade plugin-auth (nested dir, #16713) — it is minor because the rule says so. Fixes #16231 is a closing keyword, and Part-of PR must not also close its card is green (there is no Part-of: line).
CI on 951369f51: 38 check runs — 32 success, 6 skipped (Auto Label / Check PR Size duplicates, Packed-tarball smoke (opt-in) ×2, Console Pin Gate), 0 failure, 0 in_progress. mergeable_state: clean. Branch is 69 commits behind origin/main (was 17 at c5935b2), merge-base unchanged at 8ccf7a1d.
First review's findings (comment 5580313505), re-measured on 951369f51
| # | first-review finding | status @ 951369f51 |
evidence |
|---|---|---|---|
| F1 | Check Changeset red on the level axis (three consumers patch under Clause-②: yes); correct the body's "111 run, all exit 0" claim |
half discharged | level axis: all three entries minor, both Check Changeset runs success, ✓ LEVEL AXIS printed. Body correction: still open — the body is byte-identical to the one reviewed at c5935b2 (PR updated_at moved only for the label re-hang); see N1 |
| F2 | declarations have no compile-time pin; reverting to Promise<any> reddens nothing |
still open | no @ts-expect-error / expectTypeOf / assertType added anywhere in the diff (grep of origin/main...HEAD is empty); data-engine.test.ts changes are double repairs only |
| F3 | changeset runtime FROM/TO aggregated, not per door; omits the driver source | still open | changeset prose unchanged apart from the three level words; the runtime sentence is still the single "A host that installs after* handlers … now receives a refusal" |
| F4 | byIdUpdateRecord throws a bare Error with no ADR-0112 code |
still open (record) | packages/metadata-protocol/src/protocol.ts:896-905, unchanged |
| F5 | engine.ts comment "a per-row handler can reassign it" overstates; no per-row pin |
still open (observation) | engine.ts:12116-12117, unchanged |
| F6 | follow-up cards not numbered on the PR | superseded in substance, citation still open | both cards exist and predate the first review: #16786 (ctx.api.object() resolves through the class; updateById too), #16787 (type-surface-only unclaimable when the narrowed declaration lives in packages/spec/src/contracts/**), both 2026-09-08T05:22-23Z. Neither is cited in the PR body, on the PR, or in the card's patch-round comments (5582311948…5585637091 cite #16713/#16796/#16810/#16840 only) |
| F7 | 17 behind main, mergeable_state: unstable |
still open, worse | 69 behind origin/main; mergeable_state is now clean (no conflict, all required checks green), so this is hygiene rather than a merge blocker |
New findings
-
N1 — blocking (before undraft). The PR body, which becomes the merge-commit message, now contradicts the head on two counts. (a) Its Changeset section still reads "
minorfor@objectstack/specand@objectstack/objectql,patchfor the three repaired consumers" while.changeset/engine-verb-result-declarations.md@951369f51grades all fiveminor. (b) It still asserts "111 derived, 111 run … all 111 exiting 0" as covering the head, when the level axis was NOT MEASURED locally (no$GITHUB_EVENT_PATH) and was in fact red atc5935b2; and the test/typecheck table is stampedc5935b2445. The dev's own report (5585604597) records this half of F1 as "still owed" — it was carved out of the changeset-only round twice. Expectation: correct (a) with the reasoning the dev already posted (fixed version group, so the release effect is nil; the green does not gradeplugin-auth); correct (b) by stating what was measured, not by deleting the sentence; add the follow-up card numbers (F6) in the same edit. No code change. -
N2 — record.
core-boundary.ratchet.test.ts— this is a legitimate in-file avoidance, not a ratchet workaround. The ratchet (packages/objectql/src/core-boundary.ratchet.test.ts:59,:94-97) is unchanged on this branch and testsnew RegExp(`['"]${pkg}['"]`)over the raw text of every file incore.ts's closure — a QUOTED package specifier anywhere, import or not.verb-hook-result-shape.tsis in that closure (imported byengine.ts:96-103), adds no import, and simply spells "the metadata protocol layer" without the quoted specifier (:306-309, comment naming the ratchet and ADR-0076 D2). No exemption list was touched, no allowance added. The ledger comment on the same code (error-code-ledger.zod.ts,DELETE_…row) uses backticks, which the regex does not match. Nothing owed; recorded so nobody reads the comment as evidence of a suppressed ratchet. -
N3 — record. The class-vs-interface hole is real and is correctly filed, not owed here.
export class ObjectRepository implements IScopedObjectRepository(engine.ts:14557) keepsfindOne/update/deleteatPromise<any>(:14571,:14589,:14604) andScopedContext.object(name): ObjectRepository(:14728) returns the class, soctx.api.object('x').findOne()readsanyafter this PR. Runtime is not a hole: the class delegates tothis.engine.findOne/update/delete, so the three seam guards fire on that path too — only the compile-time narrowing fails to reach it. The ruling namesdata-engine.ts:276-279andscoped-context.ts:148/164, not the class, and it required a census before any declaration moved; narrowing the class needs its own (ctx.apiconsumers across hooks/flows/plugins). Judgement: outside ruling scope, correctly filed as [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 same card carriesupdateById(scoped-context.ts:168, stillPromise<any>). The metadata-protocol non-null receipts disagreement is stated atprotocol.ts:888-895and behaviour is preserved (nullpasses through unchanged) — stated, not silently changed. -
N4 — observation.
isFindOneResultShape/isUpdateResultShape/isDeleteResultShapeare declared(value: unknown): booleanrather than type predicates (value is Record<string, any> | nulletc.). Functionally identical at the seam becausehookContext.resultisany; but they are exported "so a host can check its own answer before the engine does", and a type predicate would let that host narrow. Not owed by the ruling; a one-line improvement for a follow-up.
Maintainer-only merge: yes. feat! narrowing three published .d.ts doors with Clause-②: yes and the **BREAKING** banner under the launch-window minor convention; the level for plugin-auth is not gate-measurable (#16713); the merge-commit message (PR body) is currently false on the changeset level and on verification (N1); F2 leaves the declaration half of ruling A unenforced against reversion. The PR's own text says never auto-merge, never queue; this seat agrees.
Seat boundaries: no approval/review action, no label, title, body or branch change, no merge, no file edited; refs/review/16783 deleted and confirmed absent via git show-ref.
Generated by Claude Code
Fixes #16231
Clause-②: yes
Executes the maintainer ruling on #16231 — option A, 「同意」, director seat summon #17, decision batch #2, comment
5572153316. Options B (declare only, no enforcement) and C (recordanyas intended) were refused there and nothing here reopens them.Angle brackets do not survive this body's sanitizer, so declared types are spelled with square brackets, the same convention the card uses:
Promise[Record[string, any] | null]is the real generic.What this changes
engine.tshas fourreturn hookContext.resultsites, one per hook-bearing verb. #15823 closed thefind()one — anafterFindhandler that replaced the array made a method declaredPromise[any[]]resolve to an envelope, silently — and recorded that it could close only that one, because the other three declaredPromise[any]and carried nothing a handler could break. A guard cannot exist before a declaration worth guarding does. This PR writes the three declarations down and then closes their seams.findOnePromise[any]Promise[Record[string, any] | null]driver.findOne→Promise[Record[string, unknown] | null]updatePromise[any]Promise[Record[string, any] | number | null]driver.update→ record-or-null; predicatedriver.updateMany→ affected count (#4639)deletePromise[any]Promise[boolean | number]driver.delete→ boolean; predicatedriver.deleteMany→ affected count⭐ The shapes were derived from the code, not from an assumed symmetry with
findOne.updateanddeleteeach have TWO driver exits and the dispatch ladder (resolveEngineUpdateDispatch/ its delete twin) picks between them, which is exactly why their declaration is a UNION andfindOne's is not.engine.tsstates the predicate half in its own prose at the branch that feeds the realtime publish — the sentence cites #4639 and reads: "A predicate update … an affected-row COUNT, which the strip skips as a non-object." (The verb between those two words is a closing keyword, so it is elided here rather than parked beside a card number.)Record[string, any], notRecord[string, unknown]). That is #15823's precedent extended exactly rather than softened:find()declaresPromise[any[]], so the CONTAINER is the contract and the rows inside areany. It is also the only spelling that can state "record or null" at all, sinceany | nullcollapses toanyin TypeScript. The strict alternative was measured and is reported on the card as a strictly larger option.The census — done FIRST, posted on the card before any declaration shipped
The maintainer accepted ruling A on a stated precondition: count the consumers whose compilation the narrowing breaks, and post the number on the card. Discharged in comment 5578614790.
18 files · 92 compile errors, measured by compiling all 31 workspace packages twice:
turbo run typecheck --continueon the unmodified worktree atorigin/main5e53d73d83: 142 tasks, 142 successful, exit 0. A clean baseline, so every treatment error is attributable.srcfiles / 9 sites; 15 test-and-pin files / 83 errors. ⭐ A census scoped to non-test sources would have said 3 files and been wrong by a factor of six — the pins are where this contract is encoded.TS18047: 'row' is possibly 'null': the null check the declaration exists to force.objectstack-ai/objectui, cloned at67299bf43b: 0 consumers (386 files import@objectstack/spec— the positive control fires — and zero nameIDataEngine,IObjectQLEngine,IScopedObjectRepositoryorIScopedContext).objectstack-ai/cloudis not reachable by this session and is unmeasured; so are npm consumers outside the org.Every one of the 92 is repaired here.
A second drift class the GUARD found, that the type could not
Four test doubles answered shapes their own
IDataDrivercontract does not have, invisible while the engine door saidany:{ deleted: 1 }/{ deleted: 0 }fromdelete/deleteMany—packages/spec/src/contracts/data-engine.test.ts,packages/runtime/src/seed-loader.test.ts,packages/objectql/src/engine-filter-tokens.test.tsupdateMany, which declaresPromise[number]—packages/objectql/src/plugin.integration.test.ts(two doubles)undefinedfrom a barevi.fn()onupdate/delete—packages/objectql/src/engine.test.ts(two doubles)All repaired to the declared shapes, and the
{ deleted: n }envelope now has a pin of its own so it cannot come back.find()'s refusal can say "your handler replacedctx.result" because the value at that seam always comes fromdriver.find, which every driver answers with an array. These three verbs have exits that can answer off-contract themselves, so the sentence now names the SEAM and accuses nobody — "after the 'afterUpdate' dispatch 'ctx.result' is undefined" — anddeveloperMessagenames BOTH possible sources. A sentence blaming the handler would have sent four of the repairs above to the wrong file.Symbols re-derived on
origin/main, with firing controlsThe card's own anchors (
:9565/:9784/:12103/:13537, atbdc02182b) had all drifted, so everything was re-derived by symbol viagit show origin/main:..., never from a working tree.hookContextoccurs 116 times inengine.ts, which is 15,020 lines — both matching the dispatch's stated readings exactly.return hookContext.resultsites::9599,:9818,:12137,:13572find :9439→Promise[any[]];findOne :9705,update :10871,delete :13089→ allPromise[any]packages/spec/src/contracts/data-engine.ts:276/:278/:279;scoped-context.ts:148/:164The surface the ruling does not name — reported, not absorbed
ObjectRepositoryatengine.ts:14498+is the same contract, not a separate one:export class ObjectRepository implements IScopedObjectRepository, the very interface the ruling names atscoped-context.ts:148/164. But narrowing the interface does not narrow the class, because TypeScript'simplementsacceptsPromise[any]against any declared return —anyis assignable in both directions.That matters because
ScopedContext.object(name): ObjectRepositoryreturns the class, andScopedContextISHookContext.api. ⇒ a hook author writingctx.api.object('task').findOne(...)still readsanyafter this PR; only a consumer who annotates the api asIScopedContextgets the narrowed answer. ⛔ Not fixed here — it needs its own consumer census, and widening this PR to take it is what the dispatch forbade. Same forIScopedObjectRepository.updateById(scoped-context.ts:167), stillPromise[any]; the ruling names:148/164and not:167.Verification
Two-leg ablation, blob-hash-verified restore, measured at
c5935b2445— this patch round touched no file inverb-hook-result-shape.ts's closure, so the reading stands as taken and is stamped rather than restated as if re-run. The three shape predicates inverb-hook-result-shape.tswere mutated to always answer "legal".TS6133, an unused symbol), sodistnever carried it andablation-dist-preflightcorrectly said ABSENT. The second compiled but carried its marker in a//comment, which the bundler strips — again ABSENT. Only the third, whose marker is a live string literal read through.length, is the measurement below.eb59c5c9…→352bd54d…·pnpm --filter @objectstack/objectql buildexit 0 ·ablation-dist-preflight … ABLATION_16231_GUARD_DISABLED→✓ marker present in 4 built files, exit 0 · pin suite 15 failed | 14 passed (29).git checkout HEAD -- PATH·RESTORE ok: blob eb59c5c9e87e67ceb0371bb3084d85ec70338310 == HEAD·git diff HEADfor the path EMPTY · preflight--absent→✓ marker absent from all 14 built filesand✓ tree: working tree clean against HEAD, exit 0 · pin suite 29 passed (29).Tests, working tree clean on each measurement. Every row but one was measured after the commit
c5935b2445, and this patch round changes no file those packages compile.@objectstack/specIS re-measured, on the current head10d7a9f1d9, because this round adds three cases topackages/spec/src/contracts/data-engine.test.ts:@objectstack/objectql@objectstack/spec10d7a9f1d9; 12952 atc5935b2445, and the +3 are this round's F2 pins@objectstack/metadata@objectstack/metadata-protocol@objectstack/plugin-auth@objectstack/plugin-approvals@objectstack/rest@objectstack/runtime@objectstack/service-automationOS_TEST_MYSQL_URLand unrelated to this change:src/migrations/seed-tenancy-backfill.live-mysql.test.ts(5) andsrc/migrations/sys-setting-identity-index.live-mysql.test.ts(5), bothdescribe.skipIf(!MYSQL_URL).typecheck legs.
tsc --noEmitand the per-packagecheck:test-typecheckratchet both ran. The ratchet is the leg that matters here, because it sees the test layer the plain build does not:@objectstack/objectqlOK (44 files / 242 errors / 69 pinned signatures held),@objectstack/specOK (54 / 261 / 145),@objectstack/runtimeOK (27 / 191 / 69).@objectstack/service-automationcompiles its tests through plaintsc --noEmit, which went from 30 errors to exit 0.Gate reconciliation — derived and re-run in full on the FINAL head
10d7a9f1d9(node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RANFILE, exit 0). Verbatim:⛔ What that block is — and what an earlier revision of this body claimed it was. It reconciles COVERAGE: which derived families were run. It says nothing about VERDICTS. This body previously read “…, with all 111 exiting 0” beside it and offered the pair as this PR's gate evidence. The correction is to state what was actually measured, in two parts.
1. A gate's exit 0 is not automatically a reading.
check:changeset-no-majorrun locally has no$GITHUB_EVENT_PATH, so it reads no clause-② declaration and does not judge the level axis at all — and it says so while exiting 0. Measured on head10d7a9f1d9, verbatim from the gate:⇒ that exit 0 sat inside the “all 111 exiting 0” count while the axis it names was RED on CI. The level axis was NOT MEASURED locally, and no local union run can measure it.
2. The verdicts, stated separately from the coverage, on head
10d7a9f1d9: 110 of the 111 exited 0 on the first pass.pnpm check:type-check-debtexited 3 —PREREQUISITE NOT MET, after an OOM under--max-old-space-size=4096— which is ⛔ NOT MEASURED and never a pass. Re-run at8192(the gate pins tsc itself at 6144) it exited 0 with a real reading:5 ledger entr(ies) re-measured in 177.5s, 55 raw tsc error(s) total, none above its recorded number. ⇒ 111/111 exit 0, one of them only on the second attempt.The level axis, measured directly. The gate accepts
--event, so the axis IS locally measurable once handed the payload CI reads. Same command on every leg, with this PR's real label set andClause-②: yes.GREEN on head
10d7a9f1d9— exit 0:RED on the same tree with the three consumer entries regraded
patch— exit 1:git show REV:PATH, so a working-tree edit is invisible to it. A first attempt mutated only the working tree, the gate stayed green, and that reading is reported void rather than quoted. Redone on a throwaway detached-HEAD commit (f45635a6a8), never pushed; branch ref and working tree verified unmoved afterwards.⛔ That green does NOT grade
@objectstack/plugin-auth. The red leg names TWO offenders while THREE entries were regraded.PUBLISHED_SOURCE_PATH(check-changeset-no-major.mjs:822) is anchored one directory deep, and this PR's plugin-auth path ispackages/plugins/plugin-auth/src/objectql-adapter.ts— a level deeper — so that package never enters the grown set at all. That is #16713. The third entry readsminorbecause the rule says so, not because any gate confirmed it, and ⛔ this green must not be cited as covering it.check:type-check-debt,check:type-check-coverage,check:test-source-alias,check:cross-package-test-inputs,check:dual-build-cjs-loads,check:engine-double-contract) measure the tree, so a late commit moves exactly the readings a stale run would have quoted.dispatch-gatesreported this branch at least 64 commits behindorigin/main, with 21 of the files it derives families FROM changed across that range — in its own words, “a well-formed answer about a tree nobody is on”. The 111 above is therefore this branch's family list, not currentmain's. Closing that gap is F7 of the contract review; the patch-round table below says why it is not done here.The first derivation named 88; regenerating⚠️ On the
content/docs/references/**(the error-code ledger gained three codes) grew the change set and the re-derivation named 111. The extra 23 were run and reconciled. Two went red and were fixed rather than argued:@objectstack/spec check:docs(generated docs out of date — regenerated withgen:schema && gen:docs) andcheck:doc-authoring(a tracker id had landed inside a runtime string, which reaches operators who cannot resolve one). ⛔ On that run neithercheck:type-check-debtnorcheck:dual-build-cjs-loadsrefused withPREREQUISITE NOT MET: both ran on a fully built closure and reported real measurements (5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number;entries/packages/cjsFiles/probes 104/67/620/1 vs floors 90/58/520/1).10d7a9f1d9re-runcheck:type-check-debtDID refuse once, under a 4 GB heap — measured and resolved in part 2 above; the two readings are about two runs, not a contradiction.Changeset level, and the text it is based on
minorfor@objectstack/specand@objectstack/objectql, and — since F1 of the contract review —minorfor the three repaired consumers as well, with the**BREAKING**banner and an ADR-0087 disposition.patchfor the consumers until951369f510; that grading is what F1 refused..github/workflows/pr-automation.yml: "During the launch windowmajorstays refused bycheck-changeset-no-majorand breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the level", and "A purely additive widening of a published package's public surface … takes at leastminor" — which this PR is on its own, since it adds three exported error classes, their codes and their shape predicates to@objectstack/objectql's index.AGENTS.md's "a bug fix in a released package takes a patch changeset — never none". That is the floor againstnone, not a ceiling, and reading it as one would have shipped this atpatch.check-changeset-no-majoris not by itself the justification: its level axis has a measured blind spot for nested package dirs (carded as [finding] The changeset LEVEL axis is blind to every NESTED package:packages/*/src/**matches one segment, so 51 of 74 workspace packages (all drivers/services/adapters) can pairClause-②: yeswithpatchand stay green #16713).packages/spec/*andpackages/objectql/*do match it, so it genuinely grades this one — but the argument above is the reason, not the gate.not-required (no-migration-prescription). Nothing authorable moves — no spec key, no authored metadata property, no accepted request shape, nothing forobjectstack migrate metato rewrite.type-surface-onlyis the category built for exactly this class and it is not claimed, because its predicate 2 (no-spec-diff) is mechanically false here: the surface the ruling names ISpackages/spec/src/contracts/**. Reported below rather than worked around, and ⛔ the**BREAKING**token is carried rather than dropped — dropping it is the erosion [finding] check-adr-0087-registration has no honest disposition for a published TYPE-surface narrowing — every category is refused, so the class is pushed to drop the**BREAKING**token instead #13080 was filed to stop.Contract-review patch round — handoff
5582311948The isolated
CONTRACT_REVIEW_TIERreview (comment5580313505) returned CHANGES REQUIRED, and the re-review of951369f51(card comment5586349339) restated what is still owed before undraft. Both sets, and where each item stands:minor, and correct this body's “111 run, all exit 0” claim951369f510; the body correction is under Verification abovecheck:test-typecheckpackages/spec/src/contracts/data-engine.test.ts.changeset/engine-verb-result-declarations.mdmainmergeable_state: clean; note kept for the recordpatchfor the consumers, and still claimed the 111-family run covered the head while the test table was stampedc5935b2445F2, measured in both directions. Three
@ts-expect-errorcases read the declared member type throughIDataEngine[…], so no new engine double is introduced. They are enforced bycheck:test-typecheckand never by vitest, which strips types without resolving them — the #5286 mechanism that made eighteen pins in this repo phantom checks.54 file(s) / 261 error(s) / 145 pinned signature(s) held, identical to the reading before the pins ⇒ they add zero errors to an EXACT ledger.Promise[any]and proved on disk (each anchor 1 → 0, injected marker 0 → 3, blob7907f0e2…→6b7b43bf…) — exit 1:git checkout HEAD -- PATH; blob back to7907f0e2…== HEAD, andgit diff HEADfor the path EMPTY.⇒ reverting ruling A's DECLARATION half now reddens a gate. Before this round it reddened nothing anywhere, which is exactly what F2 named (ADR-0049).
F3. The changeset's runtime half was one sentence covering three doors, and it named only the handler. It is now three FROM/TO lines, one per door, each naming BOTH sources — an
after*handler that assigned an off-declaration value, and a DRIVER whose own exit answered offIDataDriver— which is what each refusal'sdeveloperMessagealready names. The driver limbs cited are read offpackages/spec/src/contracts/data-driver.ts:findOne:181and by-idupdate:198(record-or-null),delete:209(boolean),updateMany:266/deleteMany:269(affected count).F7 — not done here, and no longer owed. The first handoff asked for⚠️ What does NOT go away is the measured consequence, kept above deliberately: the LOCAL family derivation still ran on a tree at least 64 commits behind, with 21 gate-defining files stale. CI grades the merge of head into base on every push, so the merged tree IS being judged — but no local union run on this branch has judged it, and this body does not claim otherwise.
mainto be merged in, while this round's dispatch forbade it unless a conflict actually blocked an edit — “⛔ do not merge main in unless a conflict actually blocks an edit” — and none did. That conflict is now moot: the re-review of951369f51(card comment5586349339, 2026-09-08) does not carry F7 in its owed set and readsmergeable_state: cleanwith CI 32 success / 0 failure.N1 — the two false statements this body carried, both corrected. (i) “
patchfor the three repaired consumers” — the entries have readminorsince951369f510; the Changeset-level section now says so and names F1 as the reason. (ii) the 111-family run was quoted beside a test table stampedc5935b2445— the union is now derived AND re-run in full on the current head10d7a9f1d9, every measurement block above names the head it was taken on, and@objectstack/specis genuinely re-measured there because this round changed one of its test files.验收备注
Findings met on the way, recorded here rather than fixed in this PR:
ScopedContext.object(name)returns the CLASS, so the ruled narrowing does not reachctx.api. Detailed above. A one-line annotation change plus its own consumer census. ⇒ filed as [finding] Ruling A on #16231 narrowsIScopedObjectRepository, butctx.api.object(name)resolves through the CLASSObjectRepository— the hook-facing door keepsPromise<any>, andupdateByIdkeeps it too #16786, which carries item 2 below with it.IScopedObjectRepository.updateById(scoped-context.ts:167) staysPromise[any]while itsupdatesibling narrows. Not named by the ruling. ⇒ same card, [finding] Ruling A on #16231 narrowsIScopedObjectRepository, butctx.api.object(name)resolves through the CLASSObjectRepository— the hook-facing door keepsPromise<any>, andupdateByIdkeeps it too #16786.@objectstack/metadata-protocoldeclaresrecord/datanon-null on its row receipts, whileengine.update's by-id exit can answernull—updateData's own comment states the mechanism ("the post-write readback … is alsonullwhen the row still exists but the write moved it out of the caller's row scope"). While the door saidanythis was invisible. This PR preserves the behaviour exactly and writes the disagreement down atbyIdUpdateRecordrather than widening a shipped response shape as a rider.check-adr-0087-registration'stype-surface-onlycategory cannot be claimed by the class it was built for when the narrowed declaration lives inpackages/spec/src/contracts/**. Predicate 2 isno-spec-diff, and a spec-owned contract narrowing is the most likely shape of a published type-surface narrowing in this repo. The category's own header says the alternative an author reaches for is dropping the**BREAKING**token, which [finding] check-adr-0087-registration has no honest disposition for a published TYPE-surface narrowing — every category is refused, so the class is pushed to drop the**BREAKING**token instead #13080 exists to prevent. ⇒ filed as [finding]check-adr-0087-registration'stype-surface-onlycategory is unclaimable by the class it was built for: TWO predicates refuse it whenever the narrowed declaration lives inpackages/spec/src/contracts/**#16787.core-boundary.ratchet.test.tsflags a forbidden package name in any QUOTED string incore.ts's closure, import or not. Prose in adeveloperMessagenaming the metadata protocol package reddened it. Correct-by-construction for the ratchet's purpose, surprising to hit from a message string; worked around in-file with a comment naming why.⛔ #15267 does not ride along. It is the driver-layer twin (
driver-sql/driver-tursopublishingPromise[any]on fiveIDataDriverdoors). Neither card contains the other, and ⛔ neither closes as a duplicate of the other.Draft only. ⛔ Never ready, never auto-merge, never queue — the in-seat contract-tier review runs before anything lands, and
needs:contract-reviewis hung on this PR.Generated by Claude Code