Repository navigation
fix(metadata-protocol)!: the save door refuses a hook that names a function in handler and carries no body (#21658) - #21686
Conversation
…nction in handler and carries no body A hook stored through the metadata door ships with no code package, and a handler name resolves only inside the hook's own package, so such a hook can never bind. saveMetaItem now refuses it with VALIDATION_ERROR / 400, naming the hook and its handler and prescribing a body, before anything is stored, in draft and in publish mode. A hook carrying a body beside its handler still saves. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…bound; pin the door's error body Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…d not-bound cases Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…er refusal Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…ta-hook-handler-save
📓 Docs Drift CheckThis PR changes 1 package(s): 15 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 — 11 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 6e5585e1ab9e51f374d5cd3b27158bb621a25bab && git checkout 6e5585e1ab9e51f374d5cd3b27158bb621a25bab
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 e9162b11801e7ca6f9ddbfc77693b4ab6b500b46 && git checkout -B drift-repro 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 && git merge --no-ff e9162b11801e7ca6f9ddbfc77693b4ab6b500b46
node scripts/docs-audit/affected-docs.mjs --json 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8
|
ACCEPT — PR #21686 at head
|
…dy`, including one with neither a `body` nor a `handler` (objectstack-ai#21689) (objectstack-ai#21706) Fixes objectstack-ai#21689 Clause-②: no (narrowing) This carries out triage's ruling on objectstack-ai#21689 (comment 5977077883, unlocked in 5977495602): **one predicate. The metadata save door refuses any hook with no `body`**, with a named error and the prescription "give it a `body`". The handler-only refusal that objectstack-ai#21658 landed (PR objectstack-ai#21686, `ced217ca30`) is now one case of it. `HookSchema` is untouched. ## What changes `runtimeHookWithoutBodyRefusal` in `packages/metadata-protocol/src/protocol.ts` keeps its name, its one call site in `saveMetaItem` and its one envelope. Its predicate widens from "a non-empty `handler` string and no `body` object" to "no `body` object". It is the same judgement install-local's `collectHooksWithoutBody` makes on its own door (`hookCarriesBody` in `packages/runtime/src/app-artifact-handlers.ts`). - **Envelope:** `VALIDATION_ERROR` / 400, unchanged. No code is added and the ledger is not edited. - **When and where:** unchanged from objectstack-ai#21658. It runs in draft and in publish mode, after the type-schema parse (a malformed `body` keeps the schema's located 422), and before the authoring gate and every write. - **Message, two readings of one rule:** - With a `handler` that names a function, the message is byte-identical to the one objectstack-ai#21658 shipped. It names the hook and the function. - With neither field, or an empty `handler`: "Invalid hook: 'NAME' carries no `body`, so it has nothing to 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 its `body` is the only code it can run." - Length: the handler form is 499 characters with 65-character names, as before. The neither form is 282 characters plus the hook name, well under the 500-character REST bound. - **What still saves:** a hook with a `body`, with or without a `handler` beside it. ## The PM's mechanism hypotheses, measured (at `1db5322ba2`, then rerun on the merged head `15402d98f8`) | # | Hypothesis | Reading | |:--|:--|:--| | H1 | Widen the existing predicate, one function, one call site, one code. | **Holds.** One early return removed, and the message chosen by whether `handler` names a function. The measured messages read right for both shapes (quoted above; the sweep's failure output below shows the neither text verbatim as the door produced it). | | H2 | Five suites use a bare hook as their schema-valid probe; report any others. | **Holds, with two more.** With the predicate widened and every probe untouched, the full `metadata-protocol` suite went 7 failed / 3461 passed (4 files), and the full `objectql` suite went 6 failed / 7446 passed (3 files). The five named suites, plus `metadata-protocol` `protocol.save-receipt-wording` and `objectql` `metadata-validation-sweep`. A grep of `runtime`, `rest`, `plugins/*`, `services/*`, `cli`, `verify`, `qa` and `examples/**` for hook saves through this door (`type: 'hook'` items, `/meta/hook` paths) found only `runtime`'s two pin tests, which already carry bodies. See the fixture triage below. | | H3 | `migrateStoredMetadata` and `duplicatePackage` surface a stored bare row as their recorded failure; stored rows keep their bytes. | **Holds.** Measured with a one-off harness that is not committed (the stub engine of `protocol.stored-residue-resave.test.ts`). `duplicatePackage` over a package holding one bare hook row and one body hook row answered `{ success: false, copiedCount: 1, failedCount: 1 }` with this refusal in `failed[0].error`, and the source rows' bytes were unchanged. `migrateStoredMetadata({ apply: true })` over a bare row answered `{ scanned: 1, canonical: 1, rewritten: 0, failed: 0 }` with the bytes unchanged. **Population of stored bare hooks reachable from this repository: zero.** `examples/**` and `packages/qa/**` seed no `sys_metadata` hook rows (zero `type: 'hook'` items). | | H4 | A built artifact's `handler` hook never passes `saveMetaItem`. | **Holds at `1db5322ba2`.** Every production call site either saves a fixed type other than `hook` (`automation.ts` and `flow-credential-migration.ts` for flow, `packages.ts` for app, `permission-set-projection.ts` for permission) or forwards an author's or a stored row's type. The forwarding callers are the REST `PUT /meta/:type/:name` (`rest-server.ts`), the dispatcher's metadata save (`domains/meta.ts`), `migrateStoredMetadata` and `duplicatePackage`. `AppPlugin`, `loadArtifactBundle`, the install-local door and the boot path make zero `saveMetaItem` calls. The composed pin's X and Z controls hold it end to end. | | H5 | No first-party authoring path emits a hook with neither field through this door. | **Holds for what this repository holds.** `os meta register` forwards the author's own file and emits no hook of its own. The `os` create and scaffold templates author `defineStack` sources, which reach the artifact door and never this one. `packages/mcp` has no hook tool. Studio is at the objectui pin `ab18797215`, unchanged since objectstack-ai#21658's census, which read its hook skeleton carrying a `body`. **Not measured:** the cloud AI build agent's metadata tools, which live outside this repository. | ## Fixture triage: seven probe suites move to a body-carrying hook Each probe item gains `body: { language: 'js', source: 'return;' }` and nothing else. No probe is deleted, and no assertion is changed or loosened. | Suite | What the probe measures | Still measures it | |:--|:--|:--| | `metadata-protocol` `protocol.code-only-types` (2 probes) | The objectstack-ai#5086 code-only gate does not catch `hook` (`allowRuntimeCreate` only) on either kernel; the objectstack-ai#5264 matrix shows a hook save answers a repository receipt. | Yes: `success: true` and one row on both kernels, and the receipt fields. | | `metadata-protocol` `protocol.meta-types-mint-door-agreement` | `PUT /meta/hook` behaves as `/meta/types` advertises (declared, creatable), read off one fact. | Yes: both cases are green. | | `metadata-protocol` `protocol.unrecognised-meta-type` | A declared runtime-create-only type still saves past the unrecognised-type refusal. | Yes. | | `objectql` `overlay-precedence` (3 probes: `hook`, plural `hooks`, single-kernel bypass) | The two-tier verdict: brand-new items of `allowRuntimeCreate` types pass the overlay whitelist, and a single kernel bypasses the overlay gate. | Yes. | | `objectql` `protocol-meta` (3 probes) | The PR-10d.7 two-tier model: an artifact-backed hook is refused `NOT_OVERRIDABLE` / 403; a brand-new hook and an edit of a DB-only hook are accepted. | Yes. The `NOT_OVERRIDABLE` probe was green before the move too, because the provenance gate runs first. It moved anyway, so that the refusal it measures can only be the provenance gate's. The registry-seeded items there are scenery for provenance, not saves through the door, and keep their bytes. | | **Beyond the five:** `metadata-protocol` `protocol.save-receipt-wording` (`OVERLAYLESS_PROBES.hook`) | The receipt sentence for a brand-new overlay-less type. | Yes. | | **Beyond the five:** `objectql` `metadata-validation-sweep` (`FIXTURES.hook`) | Valid gets 200; invalid gets the schema's 422 naming `events`. | Yes. The invalid fixture carries the body too, so it stays the valid fixture minus the one field the schema must name. | ## The enumeration pin, on the real door (ADR-0112: each refusal asserts `code` and `status`) Composed kernel, `packages/runtime/src/hook-handler-package-scope.pin.test.ts`, through `PUT /api/v1/meta/hook/NAME` as the signed-in administrator: | Pin | Case | Asserts | |:--|:--|:--| | 1. A `body` hook saves. | ②b (existing) | 2xx; it binds and runs, and so does one with both fields. | | 2. A handler-only hook is refused. | ② (existing) | 400 `VALIDATION_ERROR`, naming the hook and the function and prescribing a `body`; GET by name answers 404. | | 3. A hook with neither field is refused. | **②c (new)** | 400 `VALIDATION_ERROR`, naming the hook and prescribing a `body`; GET by name answers 404. | | (2 and 3) Nothing bound. | **"② and ②c nothing bound" (widened)** | After ②b's re-sync, the binder recorded no refusal of either hook and no skip of the bare one (`skipsOf`, a new reader of the engine logger's `skipping hook` warns). | | 4. A built artifact's `handler` hook through its own door is unchanged. | X and Z controls (existing) | App X's hook naming its own `functions` entry binds and runs. App Z's hook naming a function its `--artifact` runtime module exports binds and runs. | At unit level, section 8 of `protocol.invalid-metadata-422-face-inventory.test.ts` is new. It covers publish and draft with neither field, and an empty `handler`. Each case asserts `code`, `status`, the named hook, the prescription and an empty store. Section 7 (objectstack-ai#21658) is unchanged and still green. ## Reverse verification (the fix committed first, at `0c32c32bb1`) **Mutation.** `node scripts/ablation-replace.mjs` restored the old handler-only guard after the body test: `typeof hook.handler !== 'string' || hook.handler === ''` returns `undefined`, behind a marker constant `ABLATED_21689_NEITHER`. The anchor count went 1 to 0 and the blob went `3059168d5703` to `237d2559c48b`. `@objectstack/metadata-protocol` was rebuilt, and `node scripts/ablation-dist-preflight.mjs @objectstack/metadata-protocol ABLATED_21689_NEITHER` found the marker in `dist/index.js` and `dist/index.cjs`. A shell `trap` restore on EXIT, INT and TERM wrapped the whole run. The first attempt was a **no-op**, and it is disclosed here. Its replacement re-contained the anchor line, so `ablation-replace` refused it ("the anchor count moved 1 to 1"), ran nothing and proved the restore. The second attempt re-spelled the body test (`typeof hook.body === 'object' && hook.body`, the same truth table), so the anchor left the file. **Prediction:** pin 3 red, and pins 1, 2 and 4 green. **Observed:** - **Unit (src), sections 4 to 8:** 3 failed, all three section 8 cases (publish neither, draft neither, empty `handler`). 15 passed, including all of section 7 (handler-only refused in both modes, the body CONTROL, body beside handler, malformed body 422). - **Composed (dist):** - ②c failed. It received `{ status: 200 }` with `Saved hook 'scope_authored_bare' (env-wide, state=active)`. - "② and ②c nothing bound" failed. `skipsOf('scope_authored_bare')` held 3 binder skips, which also proves the new reader fires. - ① ✓, X control ✓, Z control ✓, ② ✓ and ②b ✓: 2 failed, 5 passed. **Restore.** - `ablation-replace` restored the file. The blob equals HEAD (`3059168d5703`) and `git diff HEAD` is empty. The outer trap's hash compare agreed. - 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: 26/26 (face inventory) and 7/7 (composed). ## Tests (at `15402d98f8`, after merging `origin/main` `8843505d91`, which carries PR objectstack-ai#21693) - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 210 files passed and 3 skipped; 3578 tests passed and 19 skipped. - `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 --project local`: 372 files and 7455 tests passed. - Runtime `hook-handler-package-scope.pin.test.ts` and `stored-metadata-body-boundary.pin.test.ts`: 14/14. - Typecheck, exit 0 for all three packages: - metadata-protocol `tsc --noEmit`. Its program includes all five edited test files (`--listFiles`, one hit each). - objectql and runtime: `tsc` plus `check:test-typecheck`, OK, with the debt ledgers held. Their test layers compile under `tsconfig.test.json` (`include: src/**/*`). - Dependency closure: `pnpm turbo run build --filter='@objectstack/runtime^...' --concurrency=2`, 29/29. `packages/spec` moved on main's side, so `pnpm --filter @objectstack/spec check:generated` also ran: all 15 generated artifacts are up to date. - The rest of `packages/runtime`'s suite is declared to CI. ## Gates (at `15402d98f8`) Every family below ran first at `623b4a0b94` and ran again in full on the merged head `15402d98f8`. The figures are the second run's. **Derived.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derives 66 families, the same list on both heads, and all 66 ran. - All 66 exited 0. That includes `check:dual-build-cjs-loads`: 106 published require entry points across 66 packages load. On the first head it had exited 3, PREREQUISITE NOT MET, for want of a full build. - `--ran` reconciliation: 66 accounted for, 66 run, 0 NOT-MEASURED (a derived zero), 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, 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. **Symbol-anchor sweeps**, all exit 0: - `check:adr-symbol-anchors`: 2167 anchors across 140 records. - `check:scripts-symbol-anchors`: 3760 anchors across 282 scripts. - `check:spec-docblock-symbol-anchors`: 4867 anchors across 1861 spec sources. - `check:adr-anchors`: OK. **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}']`. - **Count:** `eslint --no-inline-config --format json` over the 10 changed TS files reports 10 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/21689-hook-no-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 above. `check-adr-0087-registration --base origin/main` accepts it. ## Landing point As the claim predicted: `packages/metadata-protocol/src/protocol.ts`, `runtimeHookWithoutBodyRefusal` (`saveMetaItem`, type `hook`), plus the seven probe suites. No producer elsewhere needs a change. No governed surface is touched. PR objectstack-ai#21693, which edits the read region and the import block of `protocol.ts`, landed on main before this PR opened. It is merged in here; the merge was clean, and this diff touches neither region. ## Acceptance notes - **The draft-promotion and restore doors still do not re-ask this rule.** objectstack-ai#21658's PR recorded this for the `handler` form, and it now covers the neither form too. `publishMetaItem`, `rollbackMetaItem` and `revertCommit` can make a draft or a history version stored before this change into an active bare row, which the runtime skips at re-sync as before. Carrier: none. - **`os meta register hook --data FILE`** forwards the author's file through this door, so a bare hook file now gets this 400 and the CLI prints its message. It needs no change of its own. - **The binder's skip line** reads `skipping hook with unresolved handler` for a hook that has no `handler`. No stored row of that shape can be minted through this door any more. Noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21658
Clause-②: no (narrowing)
This carries out triage's ruling on #21658 (comment 5975986454, unlocked in 5976053780). The ruling inherits from the maintainer's ruling on #21604 (comment 5974477722, letter B) and from the install-local door precedent (#21585, PR #21615). The metadata save door refuses a body-less
handlerhook with a named error and the prescription "give it abody".HookSchemais untouched.What changes
saveMetaIteminpackages/metadata-protocol/src/protocol.tsnow refuses ahookwhosehandleris a non-empty string and that carries nobodyobject. BothPUT /api/v1/meta/hook/:nameand the dispatcher's metadata save call this door.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.handlerand carries nobody, so it can never run. Give it abody(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 ahandlername resolves only inside the hook's own package."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 ownermetadata-service, with nofunctionsmap. Both bind sites inpackages/objectql/src/plugin.tsdo this.functions, and an engine function whose owner is the bind's package. Nothing registers a function undermetadata-service.Saved hook 'scope_authored_cross' (env-wide, state=active). The binder then refused the stored hook three times (INVALID_REFERENCE/ 400, logged aterror). The ablation run below reproduces exactly this.The PM's mechanism hypotheses, measured
body(a string, say) would be told "give it abody", which misdescribes a hook that has one. After the parse,bodyis 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 malformedbodybeside ahandlergets the schema's422 INVALID_METADATAlocated atbody.bodyand ahandlerstays allowed.HookSchemadeclares both keys optional and does not make them exclusive, and the binder runsbodyfirst. Pinned at the unit level and at the composed door: the hook with both binds and runs its body, andx_stampnever runs.VALIDATION_ERROR/ 400.VALIDATION_ERROR/ 422 on its own door. This door's name refusal and its view-container refusals answerVALIDATION_ERROR/ 400, so 400 keeps one dialect per door. No new code is needed.duplicatePackageon a package holding a handler-only hook row and a body hook row answered{ success: false, copiedCount: 1, failedCount: 1 }. This refusal was infailed[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.saveMetaItemfor a hook.e9162b1180falls in one of two groups. The callers that forward an author's or a stored row's type are the RESTPUT /meta/:type/:nameand its compound twin, the dispatcher's metadata save,migrateStoredMetadataandduplicatePackage. The fixed-type callers areautomation.tsandflow-credential-migration.ts(flow),packages.ts(app) andpermission-set-projection.ts(permission).AppPlugin,loadArtifactBundle, the install-local door and the boot path make zerosaveMetaItemcalls.Scope: only the
handlerformA hook with neither a
bodynor ahandlernever runs either. Measured at the composed door:PUTanswered 200, and the binder warnedskipping hook with unresolved handler. This PR still refuses only thehandlerform, for two reasons:handlerform.protocol.code-only-types,protocol.meta-types-mint-door-agreementandprotocol.unrecognised-meta-typein metadata-protocol, andoverlay-precedenceandprotocol-metain 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
codeandstatus)PUTis refused with the named error, and nothing is stored or bound.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, thebodyprescription, 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 ofprotocol.invalid-metadata-422-face-inventory.test.ts: publish and draft mode each assertcode,statusand an empty store.x_stampnever runs. Unit: the CONTROL case and the body-beside-handler case.handlerhook is unchanged on its own door.functionsentry and binds and runs. App Z's hook names a function that its--artifactruntime module exports (loaded withloadArtifactBundle), 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-serviceowner is still pinned in objectql'shook-binder-package-scope.test.ts, which is green below.Reverse verification (the fix committed first, at
7d9d4b4221)Mutation.
node scripts/ablation-replace.mjsreplacedif (hookRefusal) throw hookRefusal;with a marker log. Anchor count 1 → 0; blob3496aca9fec3→03aa7af3511c.@objectstack/metadata-protocolwas then rebuilt, andnode scripts/ablation-dist-preflight.mjs @objectstack/metadata-protocol ABLATED_21658_HOOK_REFUSALfound the marker indist/index.jsanddist/index.cjs.Prediction: pin 1 red, pins 2 and 3 green. Observed:
expected { status: 200, … }, with the bodySaved hook 'scope_authored_cross' … state=active.Restore.
ablation-replacerestored the path: blob == HEAD (3496aca9fec3) andgit diff HEADis empty. A shell trap also rangit checkout HEAD -- ….git status --porcelainis empty.--absentpreflight found the marker in none of the 24 built files, and the tree was clean.Tests (at
e9162b1180, after mergingorigin/main7d0781482d)pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: 209 files passed and 3 skipped; 3468 tests passed and 19 skipped.typecheck. Its tsc program includes the edited test file (--listFilescount: 1).typecheck: tsc pluscheck:test-typecheck, OK, debt ledger held.hook-handler-package-scope.pin.test.tsandstored-metadata-body-boundary.pin.test.ts: 13/13.protocol-meta,overlay-precedence,plugin-authored-hooksandhook-binder-package-scope: 139/139.pnpm turbo run build --filter='@objectstack/runtime^...' --concurrency=2, 29/29.packages/runtimetests 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.check:dual-build-cjs-loadsexited 3: PREREQUISITE NOT MET. It needs a fullpnpm build, and more than 30 packages outside this closure have nodist/. NOT MEASURED. Targeted reading instead:require('./packages/metadata-protocol/dist/index.cjs')loads with 83 exports.--ranreconciliation: 64 accounted for, 63 run, 1 NOT-MEASURED, 0 UNRUN.Artifact-roster block (54 families, outside the derived total). All 54 ran.
check:error-status-conformance,check:error-code-casing,check:authz-resolver,check:route-ledger-census,check-changeset-fixedandcheck:engine-double-contract.check-closing-target-claim,check-partof-closing-keywordandcheck-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:eslint.config.mjslintsfiles: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minusNEVER_LINTED.eslint --no-inline-config --format jsonover the 3 changed TS files reports 3 files, 0 errors and 0 warnings.parserOptions.project), so this diff cannot move the verdict of any untouched file.Changeset
.changeset/21658-hook-handler-without-body-save-door.md:minorfor@objectstack/metadata-protocol,Clause-②: no (narrowing), the BREAKING banner, and the ADR-0087 markernot-required (no-migration-prescription)with the census.check-adr-0087-registration --base origin/mainaccepts it.Landing point
As the claim predicted:
packages/metadata-protocol/src/protocol.ts,saveMetaItem, typehook. No producer elsewhere needs a change.Acceptance notes
publishMetaItem,rollbackMetaItemandrevertCommitcan 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.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 servedhandlerbody 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.bodynor ahandler(see Scope).Generated by Claude Code