Repository navigation
fix(runtime,cloud-connection)!: install-local refuses a hook with no body and a job body that does not bind, and withholds such a hook on rehydrate (#21585) - #21615
Conversation
…at do not bind The one binder gains the judgements the install-local door refuses on: - collectHooksWithoutBody names every hook whose code is only a function-name handler; a door that carries no runtime module passes withholdHooksWithoutBody, so such a hook is warned and not bound. A boot binds its own handler hooks unchanged. - collectJobsWithoutRunnableBody (was collectJobsWithoutBody) also names an enabled job whose body the declaration refuses, through judgeJobBody, a parse against JobSchema.body that the job body factory now binds by too. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
… a job body that does not bind The install door's refusal of code it cannot run now covers a hook with no body and an enabled job whose body the declaration refuses, in one 422 VALIDATION_ERROR answer naming every item and its remedy. The door reads the runtime binder's own judgements, and on a rehydrate tells the binder this door carries no runtime module, so a hook with no body that an older build installed is warned and not bound. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…spec job bodies at the public door Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…dies that do not bind Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ndler-hook-refusal
📓 Docs Drift CheckThis PR changes 2 package(s): 11 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 — 28 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 be4384f85ab28c42cf077bd8bd7b458b259d2967 && git checkout be4384f85ab28c42cf077bd8bd7b458b259d2967
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 83b3d32020a12f28e8dc8e71d5bbcc5769017b33 c7144b68bcdadadac8d1e13aa8c818fdfd82fb8e && git checkout -B drift-repro 83b3d32020a12f28e8dc8e71d5bbcc5769017b33 && git merge --no-ff c7144b68bcdadadac8d1e13aa8c818fdfd82fb8e
node scripts/docs-audit/affected-docs.mjs --json 83b3d32020a12f28e8dc8e71d5bbcc5769017b33
|
…houtBody The shipped job liveness ledger anchors job/enabled's evidence on collectJobsWithoutBody, so the export keeps its name; its TSDoc now states that the judgement also covers a body that does not bind (an expression body, or one carrying body.timeoutMs). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ndler-hook-refusal
…ody that does not bind Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Contract reviewServed-tier: Read on GitHub at 2026-10-03T18:58Z, after every check-run on the head had completed. Inputs: card #21585 (body and every comment, triage's grade ① Derived judgmentsAccept-set changes the diff implies
Public-surface changes the diff implies
Security posture (the brief's two questions)
Pins against triage's five ( ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…mits that decided them (stage 11 of objectstack-ai#20595) (objectstack-ai#21617) Part of objectstack-ai#20595 Clause-②: no ## What changed Stage 11 of the `domain:engine` lane of the dead-citation sweep: `packages/drivers/driver-mongodb/**`, comment and docblock prose only, per the claim (`5971485908`). Stages 1 to 10 landed as `a7d9768ec`, `d150c3039`, `4bf4e7e70`, `13a24ece2`, `db0cf2231`, `85986144c`, `48fa7a381`, `c205b6c35`, `c98a72d69` and `fd5a1cd59`. objectstack-ai#20595 stays open: the other half of this lane is the packages this stage does not touch (`formula` 4 and `metadata-fs` 2 on the census after this stage, 6 in all), plus the test-string sites the card carries for a widened stage. Every comment or docblock site in the package that cited a tracker number answering 404 is rewritten in ruling C+D's form C (record `5749154545` on objectstack-ai#19123): the ADR when one records the decision, otherwise the commit in this repository's history that made it. That is **22 sites on 22 lines in 10 files, covering 7 numbers, plus one dead comment id on one more line**: - **9 census sites** (9 lines, 3 files under `src/`): the whole `allocated-but-absent` population of the gate's own census in this package at the base; - **10 test-comment sites** (10 lines, 6 test files), which the census defers. They carry 5 numbers: 3 the census itself reads as dead in this package's `src` (objectstack-ai#11065, objectstack-ai#13195, objectstack-ai#14428) and 2 the census never judges in this package (objectstack-ai#14434, and objectstack-ai#14917, which also stands on `tsconfig.test.json`), which the board and a single read each settle; - **3 sites outside the census glob, inside the claimed surface**: the `//` comment lines of `tsconfig.test.json` (`:1` objectstack-ai#14917, `:3` objectstack-ai#14613, `:25` objectstack-ai#14914), stage 8's precedent for the same file shape in `packages/core`. `vitest.config.ts`, `tsconfig.json`, `package.json`, `README.md` and `LICENSE` cite no dead number; - **1 dead comment-id citation**: `mongodb-11151-boolean-aggregand-answers.test.ts:13` named comment `5448627494` on objectstack-ai#11152, which answers 404 although objectstack-ai#11152 itself resolves (the card reports 24 comments and serves 16). Stage 4 rewrote the same id in `driver-sql` as 「landed as commit f6fa22c」, and this stage takes that wording. **Anchors: 7 numbers and the comment id, all by commit; 0 by ADR, 0 by repository qualifier; 7 distinct shas** (objectstack-ai#14428 and objectstack-ai#14914 share `ca3fd4b1a`: the card's fix and that pull request's own squash). 5 numbers and the comment id reuse the anchor an earlier stage measured for them; `a06faebbe` (objectstack-ai#14917) and the objectstack-ai#14914 reading of `ca3fd4b1a` are measured here. Only comments changed. Every file keeps its line count (23 lines out, 23 in, plus the changeset), so no line citation into any of them moves. No code token moves (the guard below). All 46 changed lines open with a comment marker. **No citation number is added**: on every changed line the numbers on the new text are a subset of those on the old (the only numbers on `+` lines are objectstack-ai#5286, objectstack-ai#13676 and objectstack-ai#14504 on `tsconfig.test.json`, each already on its line and each answering 200). **A `patch` changeset**: 2 of the 9 rewritten non-test lines are in the published `dist` (the `MongoDBDriver.update()` docblock in the `.d.ts` and the JavaScript, and one `//` line esbuild keeps), and `dist` is not byte-identical with the base text (see Changeset). ## H0: the package and its size The gate's own `node scripts/check-issue-citations.mjs --census --json` at base `37442d475` (the before run below), `allocated-but-absent` per remaining `domain:engine` package: | package | before | after this stage | |---|---|---| | `drivers/driver-mongodb` | **9** | **0** | | `formula` | 4 | 4 | | `metadata-fs` | 2 | 2 | | `drivers/driver-turso`, `metadata-core`, `core`, `metadata-protocol`, `objectql`, `metadata`, `drivers/driver-sql`, `drivers/driver-memory`, `drivers/driver-sqlite-wasm`, `plugins/plugin-pinyin-search`, `platform-objects` | 0 each | 0 each | The lane total goes 15 to 6. `driver-mongodb` reads 9, as at stage 10's head census (`e89bd10cd`), so the stage went ahead. ## Census: `driver-mongodb`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count is its `allocated-but-absent` findings under `packages/drivers/driver-mongodb/`. | reading | tree | board | whole-repo `allocated-but-absent` | sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `37442d475`, run 17:13:20Z to 17:16:55Z | enumerated, 195 pages, frontier objectstack-ai#21613, 19,434 records | 128 | **9** | 9 | 3 | 3 | | after | `ee337fc39`, run 17:27:02Z to 17:30:28Z | enumerated, 195 pages, frontier objectstack-ai#21614, 19,435 records (newest number read before the run objectstack-ai#21613, after it objectstack-ai#21614) | 119 | **0** | 0 | 0 | 0 | The whole-repo drop is 9, and the two finding sets differ by exactly the 9 rows of this package, removed; none was added. `resolves` (35,697), `resolves-as-pull-request` (2,383) and `cross-repo-unjudged` (1,250) did not move. The head's later commits are the changeset and one merge of `main`. The census was run a third time at the head `b69c176e9` (17:44:02Z to 17:47:23Z, 195 pages, frontier objectstack-ai#21615, 19,436 records, newest objectstack-ai#21615 before and after): whole-repo 119, `driver-mongodb` 0, and the finding set is identical to the after run, line numbers included. **Supplementary instrument, the whole package.** The census reads neither test files nor strings nor files outside `src`. A second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) over every tracked file in the package (53) and classifies each citation with the gate's `classifyCitation` against one board enumerated by the gate's `enumerateBoard` (195 pages, frontier objectstack-ai#21613, 19,434 records, read 17:17:48Z to 17:21:11Z), the same board for both readings. Every one of the 7 numbers was then read on its own over the issues endpoint (17:21:47Z): **all 7 answer 404**; the numbers that stay on changed lines (objectstack-ai#5286, objectstack-ai#13676, objectstack-ai#14504) and the controls objectstack-ai#11152, objectstack-ai#11249, objectstack-ai#11635, objectstack-ai#5346, objectstack-ai#13878, objectstack-ai#20399, objectstack-ai#15280 and objectstack-ai#12745 answer 200. | reading | citations | dead | src comment | test comment | `tsconfig.test.json` | test string | changelog | |---|---|---|---|---|---|---|---| | before, `37442d475` | 1,002 | **36** | 9 | 10 | 3 | 3 | 11 | | after, `ee337fc39` | 980 | **14** | 0 | 0 | 0 | 3 | 11 | The citation count drops by 22, the 22 rewritten tracker-number sites; no respelling stays a citation. The live counts did not move (src comment: 279 resolve, 12 as pull requests, 3 cross-repo; test comment: 264, 16 and 5; files outside `src`: 9 resolve). A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it, `CHANGELOG.md` aside) counts 700 before and 678 after: also a drop of 22. **Comment ids.** Every ten-digit run under `packages/drivers/driver-mongodb` (its `CHANGELOG.md` aside) was read: five lines, four ids. `5448627494` answers 404 and is rewritten; `5861435168` and `5865693155` (ruling records on objectstack-ai#20311 and objectstack-ai#20399, `mongodb-filter.ts:696` to `:697`, `mongodb-20444-empty-operator.test.ts:6`) and `5186668033` (`mongodb-filter.ts:807`) answer 200; the control `5971485908`, the claim, answers 200. After the rewrite the package carries four ten-digit lines, all live. No `issuecomment` or `discussion_r` link stands in the package (grep exit 1; the same grep finds them in `packages/runtime` and `packages/spec`). ## Per-number table `src` counts census sites, `test` the test-comment sites, `cfg` the `tsconfig.test.json` comment lines. Every sha matches exactly one commit (`git rev-parse --disambiguate`, count 1), is an ancestor of the base `37442d475` and of `origin/main` `54521f08c` (`git merge-base --is-ancestor`, exit 0 for all 7 on both; exit 0 is self-proving, and the clone was unshallowed first), and names the number it replaces in its message, its diff or both: 3 in the message and the diff (objectstack-ai#13195, objectstack-ai#14613, objectstack-ai#14917), 3 in the diff alone (objectstack-ai#11065 for `20950404c`, objectstack-ai#14428 for `ca3fd4b1a`, the comment id for `f6fa22ce1`), and 2 in the message alone (objectstack-ai#14434 for `93940d492`, in the subject's squash suffix; objectstack-ai#14914 for `ca3fd4b1a`, in the subject's squash suffix and a body line naming that pull request's contract-review round), where the dead number was that pull request's own and the commit is its squash. The `+` lines carry exactly these 7 nine-hex spans as new ones. `git blame` at the base puts 10 of the 23 changed lines on their anchor; the other 13 were written by a commit that cites the number as an earlier decision (`c4ecf0c49` citing objectstack-ai#11065 three times; `df1812050` citing objectstack-ai#13195 five times; `9268aec56`, which created `mongodb-exists-has-value-translation.test.ts` as a measurement before the ruling, citing objectstack-ai#13195 once; `ca3fd4b1a` citing objectstack-ai#14434 and objectstack-ai#14917; `a06faebbe` citing objectstack-ai#14613 and objectstack-ai#14914 on the file it created), and in each case the anchor is the commit that made the change the sentence credits to the number. `source` says whether an earlier stage already used this anchor for this number (`reused`) or it was measured here (`measured`). | number | src | test | cfg | anchor | kind | source | what it decided | |---|---|---|---|---|---|---|---| | `objectstack-ai#11065` | 2 | 1 | 0 | `20950404c` | commit | reused (stage 4, stage 5) | `driver-memory` counts a boolean aggregand as 1/0 in `avg` and `sum` on both its faces; it wrote `numericAggregandExpr` in `memory-analytics.ts`, the expression `mongodb-aggregation.ts:657` says it reproduces (the squash of PR objectstack-ai#11153; its diff names objectstack-ai#11065 7 times) | | `objectstack-ai#13195` | 6 | 5 | 0 | `9dac1ae01` | commit | reused (stage 5) | `$exists` means HAS A VALUE on the live mingo path, the analytics face and `translateFilter`; it wrote the `_presenceAnd` guard for `$exists` alone, the `[objectstack-ai#13195] Value-independent` comment and the note that the same clobber was reachable through `$null` and `$between`, the three things the `mongodb-filter.ts` sites credit to the number (the squash of PR objectstack-ai#13529) | | `objectstack-ai#14428` | 1 | 2 | 0 | `ca3fd4b1a` | commit | reused (stage 10) | `update()` on a missing id answers `null` on MongoDB and Turso's remote face; it wrote the `MongoDBDriver.update()` docblock and created `mongodb-update-missing-id.test.ts` | | `objectstack-ai#14434` | 0 | 1 | 0 | `93940d492` | commit | reused (stage 4, stage 5, stage 10) | declare the not-found arm on `IDataDriver.update()` (that pull request's squash); stage 10's 「Since objectstack-ai#13878 (commit 93940d4)」 for the twin sentence | | `objectstack-ai#14917` | 0 | 1 | 1 | `a06faebbe` | commit | measured | put the test layer in front of tsc via the `objectstack-ai#5286` sibling route; it created this package's `tsconfig.test.json` and graduated the `TEST_DEBT` entry (the squash of PR objectstack-ai#15465) | | `objectstack-ai#14613` | 0 | 0 | 1 | `81208086a` | commit | reused (stage 8) | `@objectstack/core` declares a typecheck script and its test layer enters the ratchet through a `tsconfig.test.json` sibling; stage 8 used it for the identical sentence in `packages/core` | | `objectstack-ai#14914` | 0 | 0 | 1 | `ca3fd4b1a` | commit | measured | that pull request's own squash; its message records the three TS18047 errors the widened `update()` declaration introduced and the type-check debt ratchet catching them (10 to 13), the event `tsconfig.test.json:25` describes | | comment `5448627494` | 0 | 1 | 0 | `f6fa22ce1` | commit | reused (stage 4) | the boolean aggregand column in the aggregation conformance fixture with ruled numeric `min` / `max` on every face; its message records the 2026-08-28 maintainer ruling (option A, superseding objectstack-ai#11249's `false` / `true`), its diff names the id 3 times, and it wrote the line | No ADR or ruling record names any of the 7 numbers as the place their decision is recorded. ## Wordings to check Most rewrites swap a tag in place (`[#N]` to `[commit SHA]`, `(#N)` to `(commit SHA)`, `#N's` to `commit SHA's`, stage 1's form). These say more than the tag: - **The dead comment id**: 「applied on that card's comment 5448627494, ruling verbatim and」 became 「landed as commit f6fa22c, ruling verbatim and」 (`mongodb-11151-boolean-aggregand-answers.test.ts:13`), stage 4's wording for the same id and the same sentence in `driver-sql`, verbatim. The quoted ruling 「12745 A回,其他同意。」 on the next line is untouched. - **A dead pull-request number, in a possessive**: 「That is how CI caught PR objectstack-ai#14914's / three TS18047 errors」 became 「That is how CI caught commit ca3fd4b's / three TS18047 errors」 (`tsconfig.test.json:25`). The errors arose on that pull request's branch and were narrowed before it landed, so the squash as landed carries none of them; its message records both the errors and the catch. This is stage 10's squash form (「objectstack-ai#5181 (PR objectstack-ai#6076)」 to 「objectstack-ai#5181 (commit 6513c17)」). The alternative, if the possessive reads wrong: 「caught, in the change commit ca3fd4b landed, its」, one line, no reflow. - **A filed defect**: 「That exclusion is itself a filed defect (objectstack-ai#14917), not a design.」 became 「… a filed defect (closed by commit a06faeb), not a design.」 (`mongodb-update-missing-id.test.ts:72`). The sentence was written the day before the fix and describes the card; stage 5's 「CLOSED by commit 9dac1ae」 is the form for a card named as an open defect. - **A file header written before the ruling**: `mongodb-exists-has-value-translation.test.ts:4` was written by `9268aec56`, the measurement that pinned the divergence while 「the direction stays undecided」; `9dac1ae01` inverted the file in place onto the ruled answer. The header now opens 「[commit 9dac1ae]」, stage 5's anchor for the twin header in `driver-memory`'s `memory-exists-has-value-faces.test.ts`. - **Sentence starts**: where the number opened a sentence, the new text opens with 「Commit」: 「// Commit 9dac1ae landed this rule for `$exists` alone」 (`mongodb-filter.ts:1476`). 「The commit 2095040 family shape」 (`mongodb-11151-boolean-aggregand-answers.test.ts:10`) is stage 4's 「the settled commit 2095040 family shape」. - **Ruling dates kept**: 「[objectstack-ai#13195, ruled 2026-08-30]」 became 「[commit 9dac1ae, ruled 2026-08-30]」 on two test lines, stage 5's form. - No line was reflowed, so some are longer than their block's wrap (`eslint.config.mjs` declares no line-length rule, and a reflow would move neighbouring lines and every line citation into the file). ## Sites left - **In comments (src, test, outside the glob): none.** - **String literals: 3 test-string sites, 2 numbers, 3 files**: the `describe` titles at `mongodb-exists-has-value-translation.test.ts:138` (objectstack-ai#13195) and `mongodb-update-missing-id.test.ts:150` (objectstack-ai#14428), and the `it` title at `mongodb-operator-key-clobber.test.ts:227` (objectstack-ai#13195). Both numbers are in this stage's table. Strings are outside this stage's surface; non-test strings cite none. - **Outside `src`:** the release-owned `CHANGELOG.md` names dead numbers on 11 sites (7 numbers); left. ## Mechanical guard: no code token moves The guard compares base `37442d475` against the tree over all 10 touched files, with TypeScript 6.0.3, to stages 2 to 10's two-reading specification (their script was a scratch file and is gone, so it was rewritten here to that specification and proven with the controls below): - **Reading 1**: the parser's leaf nodes, from a `forEachChild` walk. Comments are trivia there, and JSDoc is never visited. A leaf that is not itself a token is re-scanned with trivia skipped. - **Reading 2**: the full token stream in parser context, from a `getChildren` walk, JSDoc nodes skipped. String, template and numeric literals are compared in full on both readings. - **`tsconfig.test.json`**: reading 2 over `ts.parseJsonText`, and reading 1 replaced by the parsed config object (`ts.parseConfigFileTextToJson`) compared structurally. Results, at `ee337fc39` (the later commits touch none of the 10 files): - Real run: 20,837 base tokens, **0 files with a token change** (exit 0). - Comment controls: 「Value-independent」 to 「VALUE-independent」 (`mongodb-filter.ts`) and 「it is the BUILD」 to 「it is the BUILd」 (`tsconfig.test.json`): 0 files changed (exit 0 each). - Positive control, an identifier (`export class MongoDBDriver` to `XMongoDBDriver`, `mongodb-driver.ts`): DIFFER on both readings (exit 1). - Positive control, a string literal (the `describe` title 「… on a missing id」 to 「… on a missing iD」, `mongodb-update-missing-id.test.ts`): DIFFER on both readings (exit 1). - Positive control, a template literal (`input: ` followed by the `$${field}` template, to `$${field}x`, `mongodb-aggregation.ts`): DIFFER on both readings (exit 1). - Positive control, a numeric literal (`$lte: 100` to `101`, `mongodb-filter.test.ts`): DIFFER on both readings (exit 1). - Positive control, a config value (`"lib": ["ES2022"]` to `ES2023`, `tsconfig.test.json`): DIFFER on both readings (exit 1). Each mutation went through `scripts/ablation-replace.mjs` (wrap mode, anchor hit 1 to 0, blob changed) under a shell trap that restores by absolute path from `HEAD`. Each restore was proven equal to its `HEAD` blob (`2c4ba4f8babb`, `aebae4859583`, `48ffb45e011f`, `4f12e29dbbce`, `581fa7ca2401`, `162a1f72bae6`), with `git diff HEAD` empty and a clean tree afterwards. ## Changeset: `patch` (`dist` measured) `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is not private. In one script under the shared verify lock (VERDICT command-exit 0, held 164s, shared-box seconds), at `ee337fc39`: the dependency closure was built first (`pnpm --filter '@objectstack/driver-mongodb^...' build`, exit 0), then the package's own `build` (tsup and `check-dts-emitted`) ran three times, exit 0 each: - **Leg 1**, the head text: 6 `dist` files hashed (`index.js`, `index.mjs`, their sourcemaps, `index.d.ts`, `index.d.mts`). 2 of the 9 rewritten non-test lines appear verbatim in `dist`: the `MongoDBDriver.update()` docblock line (`mongodb-driver.ts:432`) in all four of `index.js`, `index.mjs`, `index.d.ts` and `index.d.mts`, and the `// [commit 9dac1ae] Value-independent` line (`mongodb-filter.ts:1265`) in `index.js` and `index.mjs`. The other 7 sit in comments the build drops. - **Leg 2**, the base text put back in the 3 non-test touched files (3 of 3 proven equal to their base blob, written to the tree only): 4 of the 6 files differ from leg 1 (`index.d.ts`, `index.d.mts`, `index.js`, `index.mjs`); the two sourcemaps do not. `scripts/ablation-dist-preflight.mjs` finds the base marker 「[objectstack-ai#14428] A miss answers」 in those 4 built files (exit 0). - **Leg 3**, after the proven restore (3 of 3 equal to their `HEAD` blob, `git diff HEAD` empty, porcelain empty): all 6 files are byte-identical to leg 1, and the preflight's `--absent` reading exits 0 with a clean tree, so the build is deterministic and the difference is the rewrite. So the rewrite ships, and `.changeset/20595-driver-mongodb-provenance-anchors.md` declares a `patch` for `@objectstack/driver-mongodb`, comment text only, with the claim's `Clause-②: no` line. Every anchor is a commit, so it names no ADR, repository qualifier or bracketed substitution; it says which published files carry the reworded text, as measured above. The changeset commit touches no file under `packages/drivers/driver-mongodb`. ## Gates (head `b69c176e9`) - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `b69c176e9` (11 paths against merge base `54521f08c`) derived 62 commands. All 62 ran (17:43:06Z to 17:55:21Z, after the workspace build), each exit code captured before any pipe: 62 exit 0. `--ran` reports 「62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN」 (a derived zero) and exits 0. The PM's lead derivation (48 commands, tree `ec390ec00`, one path) is a subset: the extra 14 are the eight families the `.changeset/` path adds (the ADR-0087 registration and empty-changeset pairs, `check:objectui-changeset`, `check:pm-changeset-deadline-census` and two release self-tests), `check:type-check-coverage` and `check:type-check-debt` (the `tsconfig.test.json` path), and four gates whose sources name the touched driver files (`check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:where-matcher`). - **Named readings:** `node scripts/check-issue-citations.mjs` exits 0 (「no issue citations added against 54521f0」: the `+` lines in the 3 non-test source files carry no number); `pnpm check:issue-citations` exits 0 (its self-test); `pnpm check:doc-authoring` exits 0 (the sibling-package prose-id baseline holds, no growth); `pnpm check:nul-bytes` exits 0 (9,980 files, no raw control bytes), and a control-byte grep over the 11 changed files finds none (exit 1). The changeset gates exit 0: `check-adr-0087-registration` (「1 non-breaking changeset(s) seen」), `check-empty-changeset` (「1 declaring changeset(s) added」), `check-changeset-no-major` (「no `major` bump」; its Clause-② level axis reads the pull request body, so it is not applicable to a local run and is CI's reading), and `check:changeset-gate-self-tests`. `check:type-check-coverage` and `check:type-check-debt` exit 0 (the debt re-measure: every entry at its measurement). - **Build, tests and typecheck, under the verify lock** (VERDICT command-exit 0, held 225s, shared-box seconds), at `b69c176e9`: the workspace build (`turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2`: 71 of 71 tasks, 17 cached), then `pnpm --filter @objectstack/driver-mongodb test`: 31 test files pass and 5 are skipped (36), 690 tests pass and 182 are skipped (872); the skips are the suites that need a `mongod` binary, which run only on opt-in. `pnpm --filter @objectstack/driver-mongodb typecheck` (`tsc --noEmit` and `check:test-typecheck` over `tsconfig.test.json`) exits 0. `tsc --listFilesOnly` puts all 36 tracked test files in `tsconfig.test.json`'s program, and each of the 9 changed `.ts` files in a program (the 3 non-test ones in both). No importing package owes a run: the declaration files change only in comment text. - **Lint, as a proven narrowing, at `b69c176e9`:** eslint with inline config disabled, over the 9 touched `.ts` files plus `dist/index.js` as the control and `tsconfig.test.json`: 11 results, 0 errors and 2 warnings, the control's ignore notice and 「no matching configuration」 for `tsconfig.test.json` (eslint's files patterns never name `.json`, so that file is outside its population); none of the 9 is reported ignored. `eslint.config.mjs` never enables type-aware linting (its lines 327 and 328 say so), so a comment edit cannot move the verdict on an untouched file. The repo-wide `pnpm lint` is CI's run. ## Acceptance notes - **Base and merge.** The dispatch read `origin/main` at `ec390ec00`; by the time the worktree was cut, `main` had moved one commit (`37442d475`, a release-workflow fix touching none of this package, `check-issue-citations.mjs` or `dispatch-gates.mjs`), and the branch was cut there. The clone was shallow (two shallow roots, 1,215 commits reachable) and held none of the anchors, so it was unshallowed (`git fetch --unshallow origin main`, 15,634 commits) before any blame, ancestry or history reading. The branch merges `main` once, pinned to `54521f08c` (merge `b69c176e9`, no conflict, no deferred regeneration). The two commits it brought (`0721848b8`, `spec`; `54521f08c`, a QA checklist item) touch neither `packages/drivers/driver-mongodb`, `check-issue-citations.mjs` nor `dispatch-gates.mjs`; the workspace was rebuilt after the merge, before the tests and gates. The net diff against `main` is the 10 rewritten files (+23/−23) and the changeset (+15). - **The same dead numbers outside this package**, each left to its own carrier: `driver-mongodb`'s 3 test-string sites (above); `CHANGELOG.md` (release-owned). - **Wording only:** no line without a number was changed. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… own package only (objectstack-ai#21604) (objectstack-ai#21653) Fixes objectstack-ai#21604 Clause-②: yes (narrowing) Executes the maintainer's ruling on objectstack-ai#21604 (comment 5974477722, letter B, 「同意」 2026-10-03T23:11Z): **a hook's `handler` name resolves inside the hook's own package only.** The functions the package's own runtime module registers keep resolving; a name the package does not hold is refused at registration, with a refusal that names it; `HookSchema.handler`'s declaration changes in the same PR. objectstack-ai#21585's landed install-local refusal (objectstack-ai#21615, `045b946256`) is untouched: no file of `packages/runtime` or `packages/cloud-connection` source changes here. ## Census first (the ruling's first step): zero dependents Every composition that could rely on cross-package resolution by name, read before any refusal was written: | Composition | Tree | What was read | Dependents | |:--|:--|:--|:--| | objectstack `examples/**` | objectstack `15fe567c9c` | string `handler:` values, `registerFunction` calls, `functions:` declarations, `composeStacks`, each app's hook forms | **0.** The only string `handler` is a job's (app-showcase `sweepProjectHealth`), which the job half resolves against its own bundle. app-showcase's 5 hooks all carry `body`; app-crm and app-todo each have 1 inline-function hook, which `os build` lowers to the hook's own name inside that app's own runtime module (same owner). app-multi-package and embed-objectql declare no hooks or functions. | | this repository's `--artifact` runtime modules | objectstack `15fe567c9c` | tracked `objectstack-runtime*.mjs`; tracked non-TS files naming `runtimeModule` | **0 committed.** A runtime module is a build output of an app's own config. | | hotcrm | `f24c196588` | the same greps | **0.** No string `handler:`, no `registerFunction`. Its 19 hook files are inline functions inside one `composeStacks` app (one owner), each lowered to its own name. | | objectos | `7612ffebd1` | the same greps | **0.** No hit for any of them. | | cloud | none | | **NOT MEASURED.** Unreachable from this session: a shallow clone has no credentials, a REST read answers 403 "not enabled for this session", and `add_repo` answers no access. | No stop condition fired: no real dependent was found, and owner-scoped resolution needed no new authorable spelling (see boundary flag 9). ## What changed, and where - `packages/objectql/src/hook-binder.ts`: `resolveHandler` resolves a string `handler` against the functions handed to the bind (the package's `functions`, which an `--artifact` runtime module supplies), then against the engine entry of that name **only if the entry's `packageId` equals the bind's `packageId`** (`ownPackageFunction`, reading the owner through the existing `resolveFunctionEntry`). A string handler that resolves to neither is refused at registration: an `Error` carrying `code: 'INVALID_REFERENCE'`, `status: 400`, `hook`, `handler` and `packageId`, recorded on `BindHooksResult.errors[]` (which gains optional `code` and `status`), logged at `error` with the `Error` in the logger's error slot, and thrown under `strict`. The hook is not bound. - `packages/objectql/src/engine.ts`: doc comments only (the registry and `registerFunction`). The lookup itself is unchanged: the entry already carried its owner. - `packages/spec/src/data/hook.zod.ts`: `HookSchema.handler`'s TSDoc stops declaring the engine-wide fallback ("anything `engine.registerFunction(name, fn)` added") and states the own-package rule, the refusal, and the route for a runtime-authored hook. The schema and its `.describe()` are unchanged, so no generated artifact moves (`check:generated`: all 15 up to date). - The landing matches the dispatch's expected surface; no producer elsewhere needed the fix. ## ① The accept set, before and after A hook whose `handler` is a function name and which has no `body` (a hook with a `body` binds exactly as before, body first): | Door | Before | After | |:--|:--|:--| | Boot of a code package (`AppPlugin`, a `defineStack` config, `os start --artifact`) | its own `functions` (runtime module included), then any function any package registered | its own `functions` (runtime module included), then functions its own package registered earlier; **another app's function is refused** | | Install-local (`os package install`) | handler-only hooks already refused at install and withheld on rehydrate | unchanged | | Metadata door (`PUT /api/v1/meta/hook/NAME`, bound under owner `metadata-service`) | any function any package registered | **none**: the owner registers no functions, so every handler-only authored hook is refused when the door binds it | | Multi-app composition (several apps on one engine) | app Y's hook could bind app X's function | **refused** | | Direct `bindHooksToEngine` with no `packageId` | any engine function | only the functions handed to that bind | | A name no package holds (typo) | skipped, `warn`, reason `unknown function 'NAME'` | refused: same envelope as above, `error` | ## ② Semver `minor` for `@objectstack/objectql` and `@objectstack/spec`, **BREAKING**, `!` in the title, `Clause-②: yes (narrowing)`, under the launch-window convention for narrowings of an accept set. The changeset (`.changeset/21604-hook-handler-package-scope.md`) carries the ADR-0087 disposition `not-required (no-migration-prescription)`, written from the census facts above: no authorable key, spelling, export of a published release or stored shape moves, so `objectstack migrate meta` has nothing to rewrite. (The marker sits in the changeset as the gate's comment-form marker; `check-adr-0087-registration --base origin/main` reads it green.) ## ③ Boundary flags 1. **Log level and text.** An unresolved string handler used to log `warn` with reason `unknown function 'NAME'`; it now logs `error` with the coded refusal's sentence, beside the binder's other coded registration refusal (the stored-metadata body boundary, also logged at `error` by this binder). The ruling asks for a loud refusal at registration. 2. **Result type.** `BindHooksResult.errors[]` gains optional `code` and `status` (additive output). `HOOK_HANDLER_NOT_IN_PACKAGE_CODE` and `HOOK_HANDLER_NOT_IN_PACKAGE_STATUS` are exported from `hook-binder.ts` only; neither `index.ts` nor `core.ts` re-exports them, so the package's public entry gains no symbol. 3. **The envelope (H4).** No coded refusal existed for this condition (the binder's unresolved branch carried a bare reason string). `INVALID_REFERENCE` / 400 is the standard catalog's member for a reference that does not resolve where it must. The ledger's admission rule sends a generic condition to the standard catalog, so no code is registered; `plugin-auth` already answers `INVALID_REFERENCE` for both a missing and a cross-scope reference. The sibling registration refusal's `PERMISSION_DENIED` / 403 was not reused: that refusal is about a permission on a table; this one is about a name that does not resolve, and a typo is no permission question. 4. **`strict`** (`OBJECTQL_STRICT_HOOKS=1`) throws the refusal. A strict runtime whose hook bound across packages now fails that bind, exactly as it already failed an unknown name. 5. **The metadata door (H2).** A runtime-authored hook is bound under the synthetic owner `metadata-service`, which registers no function, so a handler-only authored hook is always refused at bind. The save itself still answers as before: the pin records `200` for that `PUT`. That is the same posture as the stored-metadata body boundary, which refuses at bind and leaves the save door's answer unchanged. A `sys_metadata` hook row stamped with a `package_id` (the Studio package authoring workspace) is still bound under `metadata-service`, so it does not reach that package's runtime-module functions; before, it reached every function. Measured pull: zero string handlers anywhere in the census. Resolving by a row's own `package_id` would let any metadata author claim a package's code, which is the channel the ruling closes. 6. **A bind that names no package (H2).** With no `packageId`, a hook resolves only the functions handed to that bind; an engine entry registered without an owner is resolvable by no hook. Measured: every first-party door stamps an owner (`app:APPID`, `metadata-service`, `sys:audit`). After a platform boot (ObjectQL, sqlite-wasm, Hono, one app, platform objects, auth, security, sharing, REST, dispatcher), the engine's function registry holds exactly one entry, the app's own (`h3_fn`, owner `app:com.h3.probe`). I read "a name the package does not hold is refused" as covering a bind with no package; the reviewer may weigh that reading. 7. **The platform's own functions (H3).** None reach the engine registry. The formula stdlib's `registerFunction` registers into a `cel-js` `Environment`, not the engine (`packages/formula/src/stdlib.ts`). So no platform function's resolution changes; H3's "formula stdlib" leg is falsified. 8. **One artifact, one owner.** A multi-package artifact (`packages[]`, `composeStacks`) is bound under one owner `app:APPID`, with its functions flattened, so a hook in one composed package can still name a sibling package's function inside the same artifact. Census: app-multi-package declares no hooks or functions, and hotcrm's composition lowers each hook to its own name. Scoping inside one artifact would need per-package attribution in the bundle collectors, beyond "only as far as owner-scoped resolution needs it". 9. **No new spelling.** "Cross-package reuse must name the owning package explicitly" is met by an existing spelling: import the function from the package that owns it and declare it in your own `functions`. The refusal and the changeset prescribe exactly that, and no `pkg/fn` or `{ package, name }` form was minted. 10. **Install-local** is untouched. Its CLI integration pin (`packages/cli/test/package-install-local-hooks.integration.test.ts`, whose host hook names its own runtime-module function) is in the CLI integration tier and is declared to CI; this diff touches no CLI file. ## Pins - `packages/objectql/src/hook-binder-package-scope.test.ts`. Refusals, each asserting `code` `INVALID_REFERENCE`, `status` 400 and that the hook did not bind (the other package's function never runs): another package's function; the same under `strict` (thrown, with `hook`, `handler` and `packageId`); a name nobody holds; the metadata-door owner, read off the engine logger's `error` call; a bind with no package naming an unowned entry. Controls: a function handed to the hook's own bind; a function its own package registered in an earlier bind. - `packages/runtime/src/hook-handler-package-scope.pin.test.ts`, a composed kernel. ① Multi-app composition: app Y's hook naming app X's `x_stamp` is refused, and Y's insert is not stamped by X. ② Metadata door: `PUT /api/v1/meta/hook/scope_authored_cross` naming `x_stamp` is refused when the door binds it, while an authored `body` hook (the re-sync witness) fires. Controls: X's own hook binds and runs; app Z, loaded through `loadArtifactBundle` from an artifact whose runtime module exports `z_stamp`, binds and runs. - Re-triaged fixtures in `hook-binder.test.ts`: the two cases that pinned the text `unknown function` (the refused branch) now assert the envelope. ## Reverse verification (committed first, at `1eb671bac6`) The owner check was ablated through `scripts/ablation-replace.mjs` in WRAP mode, with an absolute-path `git checkout HEAD -- PATH` trap. The ablated `ownPackageFunction` resolves any entry by name, which is the old fallback. On-disk proof: anchor 1 → 0, replacement 0 → 1, blob `9301e0130c` → `49bf4c96cc`. `pnpm --filter @objectstack/objectql build` exited 0, and `ablation-dist-preflight` found the marker in all 4 JS files the runtime suite consumes. - objectql pins: **4 red** (another package's function, `strict`, metadata-door owner, unowned bind) and **30 green** (the typo refusal, both controls, the existing binder suite). - runtime composed pin: **2 red**, with the defect itself as the reason: Y's insert came back `|x-fn`, and the authored row came back `|x-fn|authored-body|x-fn`. **2 controls green.** - Restore: blob back to the `HEAD` blob `9301e0130c`, `git diff HEAD` empty, whole-tree `git status --porcelain` empty. After the rebuild, the marker is absent from all 14 `dist/` files and the pins are green again (34/34 and 4/4). - A first ablation run read the same red and green split, but its DTS step failed on the then-unused `packageId` parameter (the JS bundles still carried the marker). It was rerun with `void packageId;` so the build leg exits 0, and the figures above are from that clean run. ## Tests Suites at `1eb671bac6`; the later merges of `origin/main` (`b43c6fe76f`, `308ae946b9`) bring only service-analytics and CLI files, with no overlap. Build order: `turbo build --filter='@objectstack/runtime^...'`, then `--filter='@objectstack/dogfood^...' --filter=@objectstack/rest --filter=@objectstack/service-automation`, after the objectql change. - `@objectstack/objectql`: `local` project 370 files / 7441 passed; `repo` 1 / 5 passed; `typecheck` green (test layer within its pinned debt). - `@objectstack/runtime` (reads objectql's `dist/`): `local` 319 files / 4534 passed, 19 skipped; `repo` 3 / 751 passed; `typecheck` green. - `@objectstack/rest`: `local` 260 files / 4897 passed, 326 skipped; `repo` 5 / 177 passed, 1 skipped. - `@objectstack/service-automation`: 168 files / 2078 passed. - dogfood hook files (`hook-error-format`, `hook-refusal-user-facing-marking`, `hook-runas-fls`, `webhook-materialization`): 4 files / 13 passed. - `@objectstack/spec`: `check:generated`, all 15 artifacts up to date against a `dist/` whose declaration stamp matches. Direction: these are downstream consumers of objectql (runtime, rest, service-automation, dogfood); the spec edit is TSDoc only. ## Gates (at `308ae946b9`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 91 commands. That is the dispatch list plus `check-empty-changeset` (both), `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:query-options-erasure`, `check:stack-collection-maps`, `check:swallow-census-controls`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. All 91 ran, each exit code captured before any pipe, and all 91 exited 0. `--ran` reconciliation: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass, `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET: 8 packages unrelated to this diff had no `dist/` in this worktree. Those were built, and it measured green. Lint, narrowed and proven: `eslint --no-inline-config --format json` over the 6 touched TS files reports 6 files linted, 0 errors and 0 warnings. That covers every TS file in the diff under the config's `**/*.ts` and `packages/**` globs. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so the diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. ## NOT MEASURED - The cloud census: unreachable, as above. - CI-only families the derivation names, which have no local invocation: Test Core shards, Dogfood Regression Gate, Dogfood Verify CLI, Build Core, Temporal Conformance, and the workspace type-check lanes. - The CLI integration tier: declared to CI. - `check:objectui-pin-citations`: its self-test's live objectui round trip was skipped, because there is no objectui checkout here; the gate itself passed. ## Acceptance notes (observed, not filed) - `Action.target` and a flow `script` node's `config.function` still resolve through the engine's function registry by bare name (`service-automation` bridges `objectql.resolveFunction`). The ruling covers a hook's `handler` only. This is the same family on other surfaces, recorded from a code-read with no measured reach. - The registry stays keyed by bare name: two packages registering one name leave the later one's entry. A hook bound in the same call resolves its own bundle first, so boot binding is unaffected. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…whose pull does not bind (objectstack-ai#21672) (objectstack-ai#21683) Fixes objectstack-ai#21672 Clause-②: yes (narrowing) Built to triage `5975994778` (direction) and `5976336256` (unlocked once PR objectstack-ai#21668 landed as `909229e976`), dispatched under claim `5976423110`. This is PR objectstack-ai#21615's shape: one pull clause in the existing unrunnable judgement, read from the same `judgeJobPull` the binder schedules by. There is no second judge. ## What changes **The install-local door (`packages/cloud-connection`, `POST /api/v1/marketplace/install-local`, `os package install`)** now refuses a package whose enabled job declares a `pull` that does not bind. It used to install it with a 200, and the binder then warned and never scheduled the job. - "Does not bind" is exactly `judgeJobPull`'s answer: the `pull` names a mapping the package does not declare, or a mapping with no `connectorSource`, or the job declares `body` or `handler` beside the `pull`. - **One answer:** `422 VALIDATION_ERROR`, the code and status the door already gives a job `body` that does not bind. No new error code. - `describeUnrunnable` gains a pull clause beside the body clauses. It names each such job with the refusal `judgeJobPull` gives (`pull.mapping: …`), and the remedy: declare the mapping with a `connectorSource`, or correct the `pull`. `os validate` refuses the same `pull`. - A pull job is never described as a job with no `body`: that would send the author to write a `body` beside the `pull`, the shape the declaration refuses. - Nothing is registered, persisted or scheduled. `os package install` exits 1 and prints `Install failed (422 VALIDATION_ERROR)`. - A **disabled** pull job does not block its install, as a disabled body job does not. - A pull job naming a declared mapping with a `connectorSource` installs and is scheduled, as before. **The ONE binder (`packages/runtime/src/app-artifact-handlers.ts`).** - `collectJobsWithoutBody` (the landed name, kept: `packages/spec/liveness/job.json` anchors on it) now judges a job that declares `pull` by calling `judgeJobPull(job, bundle)`, the function the binder calls before it schedules a pull job. A pull that binds is not named. A pull that does not bind is named with its refusal. - `JobWithoutBody` gains one optional field, **`pullRefusal`**: the refusal `judgeJobPull` gives. A job carrying it carries no `bodyRefusal`, since a `pull` is judged before any `body` beside it, as in the binder. - The binder itself is unchanged. Its TSDoc now says install-local refuses the shape up front, so on that door the binder's pull warn fires only on a rehydrate. **Docs:** `content/docs/automation/jobs.mdx` now says the install door refuses an enabled job whose `pull` does not bind. That replaces "A `pull` job is data too, and is not refused". It also says what happens on rehydrate. **Unchanged:** `packages/spec`, `service-automation` and `objectql` are untouched. So are `JobSchema`, `MappingSchema`, the binder's scheduling, the boot door and the error-code ledger. ## A pending release note this change makes false, corrected here (confirmation requested) `.changeset/20281-job-pull-organization.md` (PR objectstack-ai#21668, not yet released) says: > `collectJobsWithoutBody` no longer names a `pull` job, so `os package install` does not refuse one. This PR makes that false. It now reads: > `collectJobsWithoutBody` does not name a `pull` job that binds, so `os package install` installs one. Nothing else in that note changed. `check-empty-changeset` names this case its DELIBERATE CORRECTION class, so **`Check Changeset` stays red on this PR by design**. That context is not required. Its own text asks for the correction to be confirmed in writing on the PR, and ⛔ never `skip-changeset`. Restoring the note from the base would ship the false sentence in the same release as this PR's own changeset. ## Measured before (A1), at the public door, on `origin/main` `eed2dee481` Measured with the new integration file below against unmodified runtime and cloud-connection `dist/`, as part of the CLI's dependency closure built at `eed2dee481`: - A pull job naming an undeclared mapping (`orders_pul`) installed with exit 0: `Package installed into the running kernel`. - The server said, at `WARN`: `[MarketplaceInstallLocal] job pull does not bind — the job is NOT scheduled: pull.mapping: this artifact declares no mapping 'orders_pul' — …`, with `{"appId":"com.example.pullmissing","job":"pull_missing_orders"}` on the line. - A pull job whose mapping has no `connectorSource` installed the same way, and the warn said `pull.mapping: mapping 'orders_pull' declares no connectorSource, so there is nothing to pull — …`. - Both packages were in the install-local ledger, and neither job was ever scheduled (no `sys_job` row). - The file's three refusal pins went red and its five controls went green. ## One judge (A2) - The collector calls `judgeJobPull(job, bundle)`. That is the same function, with the same arguments, that `scheduleAppArtifactJobs` calls before it schedules a pull job. The door only formats the `pullRefusal` it is handed, and does not re-judge or paraphrase the question. - Pinned in the runtime unit: on one bundle, every pull job the collector names is one the binder does not schedule. Every enabled pull job it does not name, the binder schedules. Each named job's `pullRefusal` is the exact tail of the warn the binder logs when it withholds that job. - `collectJobsWithoutBody` and `JobWithoutBody` are not renamed. `packages/spec/liveness/job.json` anchors `job/enabled` on the collector and the `pull` row on `judgeJobPull`. Both rows are unchanged, and `scripts/liveness/evidence.test.ts` passes (42). ## Rehydrate (A4): it already held, so it is pinned, not coded On `origin/main` the binder's existing skip already withheld a non-binding pull job of a persisted entry and warned with the job's name in the line's meta. The rehydrate pins in the integration file were green before the fix and stay green after it. No rehydrate code was added. ## Pins | Pin | Where | |:---|:---| | An undeclared mapping is refused at the public door | `packages/cli/test/package-install-local-jobs-pull.integration.test.ts`: exit 1, `Install failed (422 VALIDATION_ERROR)`, names the job, `pull.mapping: …` and `os validate`; not in the ledger, no `sys_job` row. `cloud-connection` `marketplace-install-local-jobs.test.ts`: 422, nothing registered, persisted or scheduled (not even a valid body job beside it), and the no-`body` clause is not used. | | A mapping with no `connectorSource` is refused the same way | CLI integration (exit 1, names the job and the reason); cloud-connection unit | | One answer names every kind | cloud-connection unit: a handler-only job and an unbindable pull job in one 422 | | Control: a declared mapping installs and is scheduled | CLI integration: exit 0, its `sys_job` row, and a `sys_job_run` row per run. Each run reaches the automation service's pull door, which records `failed` because the package declares no `connectors[]` entry. That is the run's verdict, not the install's. cloud-connection unit: 200, scheduled, and a run calls `pullConnectorSource` with the mapping. | | A disabled unbindable pull job installs | CLI integration (exit 0, no `sys_job` row); cloud-connection unit | | Rehydrate of an entry an earlier build persisted withholds the job | CLI integration, second boot over a ledger entry: the bindable pull job of the entry is scheduled and runs, the unbindable one has no `sys_job` or `sys_job_run` row, and a `WARN` line names it with `pull.mapping: …`. cloud-connection rehydrate unit: the same, with the warn's `job` meta. | | One judge | runtime `app-artifact-handlers.job-pull.test.ts` (the two pins above) | The runtime pin that asserted the old behaviour, `collectJobsWithoutBody never names a pull job`, is replaced by the two collector pins above. ## Reverse verification (A5) One leg went through `scripts/ablation-replace.mjs` in WRAP mode, with its restore trap held by the tool. It was rebuilt, checked with `scripts/ablation-dist-preflight.mjs`, then measured. The leg was taken on committed `413869dfe1`. The door's acceptance condition has no pull-specific term: it refuses on `unrunnable.jobs.length`. So the door's pull clause, as a judgement, is the collector's pull leg, and that is what was ablated. Ablating `describeUnrunnable`'s sentence alone would leave the 422 standing and change only prose. | Leg | Anchor → mutation | Blob | dist preflight | Went red | Stayed green | Restore | |:---|:---|:---|:---|:---|:---|:---| | The collector's pull leg (the door's pull judgement) | `if (judged.binds) continue;` + newline + `pullRefusal = judged.refusal;` → the same with `\|\| String('ABLATED_21672_PULL') !== ''` added to the condition, so every pull job is skipped, as on `main` | `d207bdb16564` → `f4e6ffa8924b` | marker present in `dist/index.js` and `index.cjs` | runtime collector unit 2/21. cloud-connection jobs unit 3/17: both refusals and the one-answer pin. CLI integration 3/8: both refusals, CLI printed `Package installed`, and the ledger pin. | the declared-mapping control, the disabled pull job, and the rehydrate pins (all three layers) | the tool: blob `d207bdb16564` == HEAD, `git diff HEAD` empty. Rebuilt, then `--absent`: marker absent from all 6 built files, tree clean. | The direction was red, as expected. The tool refused a first attempt before running anything: that replacement still contained the anchor, so the anchor count could not drop. It restored the file and nothing was measured. ## Verification (at `413869dfe1`) All runs are at `413869dfe1`, the final commit, with build and test runs under `os-verify-lock`. `origin/main` has since moved one commit, to `7d0781482d`. That commit touches only `.claude/skills/pm-dispatch/references/execution-duties.md`, so this branch was not merged again. - `@objectstack/runtime`: `typecheck` green, including `check:test-typecheck`. Full suite (`vitest run --project local`): 320 files, 4555 passed, 19 skipped. - `@objectstack/cloud-connection`: `typecheck` green, including `tsconfig.test.json`. Full suite: 36 files, 443 passed. - `@objectstack/cli`: `typecheck` green. Its test-layer program compiles the new integration file, counted with `--listFilesOnly` (1 hit). `--project unit`: 257 files, 3771 passed. - The install-local integration pins, on built `runtime` and `cloud-connection` `dist/` (the pull clause present in both door bundles, the ablation marker absent): `package-install-local-{jobs-pull,jobs,jobs-shared-name,hooks,handlers,boot-steps,uninstall-cleanups}`, 7 files, 76 passed. - `pnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts`: 42 passed. Every touched symbol was grepped across `packages/spec/liveness/**` and `*.ledger.*`: only the two unchanged anchors hit. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; 9 paths vs merge base `eed2dee48`): 93 commands. 92 exit 0, and 1 exits 1 by design: `check-empty-changeset --base origin/main`, the pending release note corrected above. `--ran` reports 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN. - `check:skill-examples` and `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`), because packages outside this diff's closure were unbuilt. Both exited 0 on rerun once those packages were built. The record carries the reruns. - Full `pnpm lint` (`eslint . --no-inline-config`): exit 0, no findings. ## Acceptance notes - `content/docs/references/system/job.mdx` is generated from `JobSchema.body`'s describe in `packages/spec`. It says `os package install` "refuses an enabled job with no `body` (a `pull` job excepted: it is data too)". That stays literally true, because the exception is from the no-`body` refusal. It is not edited, since `packages/spec` is out of this card's surface. The next PR that touches `packages/spec/src/system/job.zod.ts` could add that an unbindable `pull` is refused too. Not filed. - Version skew: a newer `@objectstack/runtime` behind an older `@objectstack/cloud-connection` would describe an unbindable pull job with the no-`body` clause. That is the wrong remedy, though still a 422. The two packages are in one `fixed` release group in `.changeset/config.json`, and the door already tells an operator to upgrade them together. Not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…nction in `handler` and carries no `body` (objectstack-ai#21658) (objectstack-ai#21686) Fixes objectstack-ai#21658 Clause-②: no (narrowing) This carries out triage's ruling on objectstack-ai#21658 (comment 5975986454, unlocked in 5976053780). The ruling inherits from the maintainer's ruling on objectstack-ai#21604 (comment 5974477722, letter B) and from the install-local door precedent (objectstack-ai#21585, PR objectstack-ai#21615). **The metadata save door refuses a body-less `handler` hook with a named error and the prescription "give it a `body`".** `HookSchema` is untouched. ## What changes `saveMetaItem` in `packages/metadata-protocol/src/protocol.ts` now refuses a `hook` whose `handler` is a non-empty string and that carries no `body` object. Both `PUT /api/v1/meta/hook/:name` and the dispatcher's metadata save call this door. - **Envelope:** `VALIDATION_ERROR` / 400. This is the envelope of the name check the same door runs on every body (`savedItemNameRefusal`). No new code is added, and the ledger is not edited. - **Message:** it names the hook and the function and gives the prescription before the explanation. It stays under the 500-character REST message bound when each name is shorter than about 65 characters. The measured text reads: "Invalid hook: 'scope_authored_cross' names the function 'x_stamp' in its `handler` and carries no `body`, so it can never run. Give it a `body` (sandboxed JS, `{ language: 'js', source }`, or an expression), which is stored with the hook. A hook saved through the metadata API ships with no code package, so it holds no functions, and a `handler` name resolves only inside the hook's own package." - **When:** in draft mode and in publish mode, before anything is stored or bound. - **Where in the door:** right after the type schema accepts the body, and before the runtime authoring gate and every write. H1 below explains the placement. The diff adds one module-level helper with its TSDoc, `runtimeHookWithoutBodyRefusal`, and one call site. ## Why such a hook can never bind (measured) - `ObjectQLPlugin`'s authored-hook re-sync binds every stored hook under the synthetic owner `metadata-service`, with no `functions` map. Both bind sites in `packages/objectql/src/plugin.ts` do this. - Since PR objectstack-ai#21653, the binder looks a name up in two places only: the bind's own `functions`, and an engine function whose owner is the bind's package. Nothing registers a function under `metadata-service`. - So the name has nothing to bind to. Before this change, the door answered 200 with `Saved hook 'scope_authored_cross' (env-wide, state=active)`. The binder then refused the stored hook three times (`INVALID_REFERENCE` / 400, logged at `error`). The ablation run below reproduces exactly this. ## The PM's mechanism hypotheses, measured | # | Hypothesis | Reading | |:--|:--|:--| | H1 | The check sits beside the view checks. | **Falsified in part, by choice.** The check sits one step later, right after the type-schema parse. Beside the view checks, a hook with a malformed `body` (a string, say) would be told "give it a `body`", which misdescribes a hook that has one. After the parse, `body` is either absent or a declared hook body, so the binder's body-first test is exact. The check still runs before the authoring gate and before every write. A pin covers this: a malformed `body` beside a `handler` gets the schema's `422 INVALID_METADATA` located at `body`. | | H2 | The predicate. A hook with both a `body` and a `handler` stays allowed. | **Holds.** `HookSchema` declares both keys optional and does not make them exclusive, and the binder runs `body` first. Pinned at the unit level and at the composed door: the hook with both binds and runs its body, and `x_stamp` never runs. | | H3 | `VALIDATION_ERROR` / 400. | **Holds.** Install-local answers `VALIDATION_ERROR` / 422 on its own door. This door's name refusal and its view-container refusals answer `VALIDATION_ERROR` / 400, so 400 keeps one dialect per door. No new code is needed. | | H4 | The re-savers record a failure, and stored rows keep their bytes. | **Holds.** Measured with a one-off harness that is not committed. `duplicatePackage` on a package holding a handler-only hook row and a body hook row answered `{ success: false, copiedCount: 1, failedCount: 1 }`. This refusal was in `failed[0].error`, and the source row's bytes were unchanged. `migrateStoredMetadata({ apply: true })` on such a row answered `{ scanned: 1, canonical: 1, rewritten: 0, failed: 0 }`, with the bytes unchanged. No conversion is pending for such a row, so it is never re-saved. | | H5 | No artifact or install path calls `saveMetaItem` for a hook. | **Holds.** Every call site at `e9162b1180` falls in one of two groups. The callers that forward an author's or a stored row's type are the REST `PUT /meta/:type/:name` and its compound twin, the dispatcher's metadata save, `migrateStoredMetadata` and `duplicatePackage`. The fixed-type callers are `automation.ts` and `flow-credential-migration.ts` (flow), `packages.ts` (app) and `permission-set-projection.ts` (permission). `AppPlugin`, `loadArtifactBundle`, the install-local door and the boot path make zero `saveMetaItem` calls. | ## Scope: only the `handler` form A hook with neither a `body` nor a `handler` never runs either. Measured at the composed door: `PUT` answered 200, and the binder warned `skipping hook with unresolved handler`. This PR still refuses only the `handler` form, for two reasons: - The ruling and the claim name only the `handler` form. - The bare shape is the schema-valid probe body in at least five existing suites: `protocol.code-only-types`, `protocol.meta-types-mint-door-agreement` and `protocol.unrecognised-meta-type` in metadata-protocol, and `overlay-precedence` and `protocol-meta` in objectql. Widening the predicate is a separate call. It goes to the seat as a finding and is not folded in here. ## Pins (ADR-0112: each refusal asserts `code` and `status`) | Pin (triage 5975986454) | Where | |:--|:--| | 1. The measured `PUT` is refused with the named error, and nothing is stored or bound. | **Composed kernel**, `packages/runtime/src/hook-handler-package-scope.pin.test.ts`. Case ② asserts 400, the body `{ error, code: 'VALIDATION_ERROR' }`, the names of the hook and the function, the `body` prescription, and a 404 on the by-name GET. Case "② nothing bound" asserts that the binder recorded no refusal of the hook after the re-sync ran. **Unit**, section 7 of `protocol.invalid-metadata-422-face-inventory.test.ts`: publish and draft mode each assert `code`, `status` and an empty store. | | 2. A body hook saves and binds. | **Composed** case ②b: a body hook and a body-plus-handler hook both bind and run, and `x_stamp` never runs. **Unit**: the CONTROL case and the body-beside-handler case. | | 3. A built artifact's `handler` hook is unchanged on its own door. | **Composed** controls. App X's hook names its own `functions` entry and binds and runs. App Z's hook names a function that its `--artifact` runtime module exports (loaded with `loadArtifactBundle`), and it binds and runs. | Before this PR, the composed case ② recorded the door's 200 and asserted the refusal at bind. It now asserts the refusal at the door. The binder's refusal for the `metadata-service` owner is still pinned in objectql's `hook-binder-package-scope.test.ts`, which is green below. ## Reverse verification (the fix committed first, at `7d9d4b4221`) **Mutation.** `node scripts/ablation-replace.mjs` replaced `if (hookRefusal) throw hookRefusal;` with a marker log. Anchor count 1 → 0; blob `3496aca9fec3` → `03aa7af3511c`. `@objectstack/metadata-protocol` was then rebuilt, and `node scripts/ablation-dist-preflight.mjs @objectstack/metadata-protocol ABLATED_21658_HOOK_REFUSAL` found the marker in `dist/index.js` and `dist/index.cjs`. **Prediction:** pin 1 red, pins 2 and 3 green. **Observed:** - **Unit (src):** publish ✗ and draft ✗. CONTROL ✓, body beside handler ✓, malformed body ✓. 2 failed, 21 passed. - **Composed (dist):** - ② ✗: `expected { status: 200, … }`, with the body `Saved hook 'scope_authored_cross' … state=active`. - "② nothing bound" ✗: the binder recorded 3 refusals. - ②b ✓, ① ✓, X control ✓, Z control ✓. - 2 failed, 4 passed. **Restore.** - `ablation-replace` restored the path: blob == HEAD (`3496aca9fec3`) and `git diff HEAD` is empty. A shell trap also ran `git checkout HEAD -- …`. - Whole-tree `git status --porcelain` is empty. - After a rebuild, the `--absent` preflight found the marker in none of the 24 built files, and the tree was clean. - The reruns are green: 23/23 and 6/6. ## Tests (at `e9162b1180`, after merging `origin/main` `7d0781482d`) - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 209 files passed and 3 skipped; 3468 tests passed and 19 skipped. - Typecheck, exit 0 for both packages: - metadata-protocol `typecheck`. Its tsc program includes the edited test file (`--listFiles` count: 1). - runtime `typecheck`: tsc plus `check:test-typecheck`, OK, debt ledger held. - Runtime `hook-handler-package-scope.pin.test.ts` and `stored-metadata-body-boundary.pin.test.ts`: 13/13. - objectql `protocol-meta`, `overlay-precedence`, `plugin-authored-hooks` and `hook-binder-package-scope`: 139/139. - Dependency closure: `pnpm turbo run build --filter='@objectstack/runtime^...' --concurrency=2`, 29/29. - The `packages/runtime` tests outside these files are declared to CI. ## Gates (at `e9162b1180`) **Derived.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derives 64 families, and all 64 ran. - 63 exited 0. - `check:dual-build-cjs-loads` exited 3: PREREQUISITE NOT MET. It needs a full `pnpm build`, and more than 30 packages outside this closure have no `dist/`. NOT MEASURED. Targeted reading instead: `require('./packages/metadata-protocol/dist/index.cjs')` loads with 83 exports. - The `--ran` reconciliation: 64 accounted for, 63 run, 1 NOT-MEASURED, 0 UNRUN. **Artifact-roster block** (54 families, outside the derived total). All 54 ran. - 51 exited 0. These include `check:error-status-conformance`, `check:error-code-casing`, `check:authz-resolver`, `check:route-ledger-census`, `check-changeset-fixed` and `check:engine-double-contract`. - 3 exited 2 and are NOT WIRED without PR context: `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`. They are rerun with this PR's context, and the results go in the os-dev report. **Lint.** CI owns `pnpm lint`. This PR records a proven narrowing instead: - **Population:** `eslint.config.mjs` lints `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`. - **Count:** `eslint --no-inline-config --format json` over the 3 changed TS files reports 3 files, 0 errors and 0 warnings. - **Invariance:** the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict of any untouched file. ## Changeset `.changeset/21658-hook-handler-without-body-save-door.md`: `minor` for `@objectstack/metadata-protocol`, `Clause-②: no (narrowing)`, the BREAKING banner, and the ADR-0087 marker `not-required (no-migration-prescription)` with the census. `check-adr-0087-registration --base origin/main` accepts it. ## Landing point As the claim predicted: `packages/metadata-protocol/src/protocol.ts`, `saveMetaItem`, type `hook`. No producer elsewhere needs a change. ## Acceptance notes - **The draft-promotion and restore doors do not re-ask this rule.** `publishMetaItem`, `rollbackMetaItem` and `revertCommit` can still make a draft or a history version stored before this change into an active handler-only row. The runtime then refuses that row at bind, as before. The rule covers the save door only, as the same door's view-container refusal does. Carrier: none. - **Kernels with no `environmentId`.** A save there under the name of an artifact-shipped hook writes a row the re-sync skips (`isArtifactShippedHook`). So a GET-then-PUT round trip of an artifact hook's served `handler` body is now refused on such a kernel. Before, it stored an inert row that was never bound. Environment-scoped kernels already refuse that write (`refusePackagedBaseOverride`). Carrier: none. - **One finding goes to the seat in the os-dev report:** a hook with neither a `body` nor a `handler` (see Scope). --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21585
Clause-②: yes (narrowing)
Triage's retriage answer
5970803032(Q1: A), re-claimed in5970905860. This is #21489's ruling C applied to hooks and completed on rehydrate, with the off-spec jobbodyfolded in.What changes
The install-local door (
packages/cloud-connection,POST /api/v1/marketplace/install-local,os package install) refuses two more kinds of package it used to install with a 200. The refusal sits in the same step as the enabled-job refusal and gives the same response.body. A function-namehandlernames code that travels only in an artifact's runtime module, never in the JSON this door installs. So on this door such a hook could never bind to the package's own code. It used to install and then either never fire, or bind by name to a function the package does not ship.bodyandhandlerinstalls, because its body wins, as in the binder.bodydoes not bind. The door used to judge only that a jobbodywas present. It now judges that the body binds, using the declaration's own parse ofJobSchema.body.body.timeoutMs, is refused instead of installing and never being scheduled.422 VALIDATION_ERROR, the code the job refusal already used.body, oros start --artifact.os package installrenders it unchanged, with no special case:Install failed (422 VALIDATION_ERROR): ….VALIDATION_ERRORfits under the ledger's admission rule for a generic validation condition. The reasoning is in the constant's doc.The ONE binder (
packages/runtime/src/app-artifact-handlers.ts) owns both judgements, so the door and the binder cannot disagree:collectHooksWithoutBody: a hook whosebodyis not a body object. This mirrors the engine binder's own body-first test, so exactly the hooks whosehandlerthe engine would resolve by name are named.bindAppArtifactHandlerstakeswithholdHooksWithoutBody, which only install-local sets because it carries no runtime module.bodyis warned by name and not bound.withheldHooks.collectJobsWithoutBody(the landed name, kept) also names an enabled job whosebodydoes not bind: an expression body, or one carryingbody.timeoutMs. Its TSDoc says so. It readsjudgeJobBody(sandbox/body-runner.ts), a parse againstJobSchema.shape.body.timeoutMscheck.invalid job.body shape — the job is NOT scheduled, and now carries the declaration's sentence.collectJobsWithoutBodyandJobWithoutBodyare unchanged frommain, because the shipped job liveness ledger anchorsjob/enabled's evidence on that symbol. This round's first head renamed them, and CI's@objectstack/specrepo tier caught it. The landed names are restored, with no alias and nopackages/specedit.Docs:
content/docs/automation/jobs.mdxnow says the install door also refuses an enabled job whosebodydoes not bind. No hand-written page states how a hook'shandlerbehaves onos package install, so there was no hook sentence to correct.Unchanged:
os start --artifactand adefineStackconfig carry their runtime module: an app's own handler hooks bind to its own functions exactly as before. This is pinned at the public door and in the binder unit.objectql,packages/specor error-code-ledger edit.handlername bind to a function another package registered (the engine-wide fallback HookSchema.handler declares), or does name resolution stay inside the hook's own package (#21585 option B) #21604, which remains open and is not done here.scheduleAppArtifactJobs's scheduling identity is untouched. [finding] Two install-local packages that declare the same job name: the second install silently replaces the first package's job, which stops running, and the door says nothing #21602 is held behind this PR.Measured before
These readings were taken on
main6c5697dffbthrough the public door, plus one unit reading on one engine, in os-dev report5970542332.handlerform and nobodyinstalled with exit 0.body.timeoutMsjob body each installed with exit 0 and were never scheduled.Pins
5970803032)packages/cli/test/package-install-local-hooks.integration.test.ts(exit 1,Install failed (422 VALIDATION_ERROR), names hook and remedy, nothing in the ledger);cloud-connectionmarketplace-install-local-hooks.test.ts(422, nothing registered, persisted or bound)cloud-connectionrehydrate unit;runtimebinder unit on a realObjectQLengine (another app's function of the same name never runs)body); cloud-connection unit (bound underapp:MANIFEST_ID)--artifactapp's own handler hooks still bindbody.timeoutMseach exit 1 naming the key, 0 rows; a valid body job exits 0 and writes rows); cloud-connection jobs unit; runtime collector unit, including that every job named is not scheduled and every enabled body job not named isReverse verification
Each leg went through
scripts/ablation-replace.mjsin WRAP mode, with its restore trap held by the tool. Each was rebuilt, then checked withscripts/ablation-dist-preflight.mjs, then measured. Each restore was proven by blob equals HEAD and an emptygit diff HEAD. Each was rebuilt again and checked with the preflight in the opposite mode, and finished with the whole tree clean. All were taken from committede961c7f5d5, before the main merge.if (unrunnable.jobs.length > 0 || unrunnable.hooks.length > 0) {→ drops the hooks term83b6de87a2b1→64f0fb4337e1--absent 'unrunnable.hooks.length > 0': absent from all 6 built files (it was in pristine dist, 1 hit inindex.js)Package installed)83b6de87a2b1== HEAD, diff empty; rebuilt: marker back in 2 built files, tree cleanif (options.withholdHooksWithoutBody) {→… && String('ABLATED_21585_B') === '') {8879aeae73fd→ee32244b3f9adist/index.jsandindex.cjshostbody, so the cross-app binding is back, and no warn appearedif (judged.binds) continue;→if (judged.binds || String('ABLATED_21585_C') !== '') continue;8879aeae73fd→9e40620a0d48dist/index.jsandindex.cjsThe direction was as expected on every leg: red.
Verification (at
c7144b68bc, after mergingmain83b3d32020)1b41b79d2dispnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts: 42 passed. The wholepnpm --filter @objectstack/spec test:repo: 51 files, 879 passed.pnpm --filter '@objectstack/cli...' buildunderos-verify-lock: VERDICT command-exit 0.@objectstack/runtime:typecheckgreen, includingcheck:test-typecheck. Fulltest: 318 files, 4495 passed, 19 skipped.@objectstack/cloud-connection:typecheckgreen. Fulltest: 36 files, 437 passed.@objectstack/cli,--project unit: 255 files, 3745 passed.package-install-local-{hooks,jobs}: 19 passed.package-install-local-{handlers,boot-steps,uninstall-cleanups}: 40 passed.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 93 commands, all run, including the docs families thejobs.mdxedit brings in.--ranreports 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN.check:skill-examplesandcheck:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET, unrelated packages not yet built). Both exited 0 on rerun once a later gate in the run had built those packages. The record carries the reruns.mainmoved to5c9138b4b6after this merge (objectqlanddriver-sql). The derivation did not call the tree stale, so the branch was not merged again.check:error-code-casing,check:route-ledger-census,check:authz-resolver,check:filter-alias-parity,check-changeset-fixed,check:engine-double-contract,check:error-status-conformance.pnpm lintatc7144b68bc: exit 0.e961c7f5d5. The name restoration changes no logic, and the anchors it used are unchanged.Acceptance notes
bodyIS an object the hook body runner refuses is warned and not bound on every door. The binder does not fall back to itshandler. The door does not refuse that shape: the ruling folded in job-body bindability only. Observation; carrier: none.5970905860).Generated by Claude Code