Skip to content

fix(metadata-protocol)!: the save door refuses a hook that names a function in handler and carries no body (#21658) - #21686

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21658-meta-hook-handler-save
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21658-meta-hook-handler-save

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 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 fix(objectql,spec)!: a hook's handler name resolves inside the hook's own package only (#21604) #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

claude added 5 commits October 4, 2026 04:38
…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 3 documentable anchor(s).

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))
  • content/docs/api/error-catalog.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))
  • content/docs/api/error-handling-client.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))
  • content/docs/api/error-handling-server.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))
  • content/docs/automation/jobs.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))
  • content/docs/automation/webhooks.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))
  • content/docs/concepts/metadata-lifecycle.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/drivers.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/kernel/error-handling.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))
  • content/docs/protocol/objectql/types.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))
  • content/docs/ui/forms.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))
  • content/docs/releases/v17/17-5.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))
  • content/docs/releases/v17/17-6.mdx (via VALIDATION_ERROR (literal, a string literal in runtimeHookWithoutBodyRefusal; a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6e5585e1ab9e51f374d5cd3b27158bb621a25bab — the merge of head e9162b11801e7ca6f9ddbfc77693b4ab6b500b46 into base 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT — PR #21686 at head e9162b1180

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-04T05:48Z. The os-dev report is on #21658. Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main, assignee os-project-manager.
    • The first lines are Fixes #21658 and Clause-②: no (narrowing).
    • The closing-keyword scan finds #21658 only.
  • Scope: 4 files, +232/-26: protocol.ts (one helper and one call site), two test files and the changeset. NOT governed. No packages/spec file is touched.
  • The diff, read:
    • runtimeHookWithoutBodyRefusal refuses a hook whose handler is a non-empty string and that carries no body object, with VALIDATION_ERROR / 400.
    • It is called after the type-schema parse and before the authoring gate and every write, so it applies in draft and publish mode alike.
    • A hook with both a body and a handler saves. That is the binder's own body-first test, and install-local accepts the same shape.
    • The message names the hook and the function, and prescribes a body first, inside the 500-character REST bound.
    • The body languages it names exist: HookBodySchema is the discriminated union of the expression and js bodies (hook-body.zod.ts:258).
  • Deviation (H1, placement after the parse) — accepted. A malformed body keeps the schema's located 422 instead of being told to add a body. The malformed-body case pins it.
  • Deviation (the existing composed pin case ②) — accepted. That case saved the shape and asserted the bind-time refusal, and this change makes the save impossible, so it now asserts the door refusal. The binder's metadata-service refusal stays pinned in objectql's hook-binder-package-scope.test.ts.
  • H5 — checked against the ruling's "a built artifact's handler hook through its own door is unchanged". No artifact, boot or install-local path calls saveMetaItem for a hook: every call site saves a fixed other type, or forwards an author's /meta save, migration or duplication. The acceptance note about an artifact hook round-tripped through the metadata door on an environmentId-less kernel describes a save through THIS door, not the artifact's own. It used to store an inert row that never bound. It is accepted as the ruling's scope.
  • Clause-②: no (narrowing) — accepted. There is no path leg, and nothing widens. No contract review is owed.
  • Changeset, checked sentence by sentence:
    • minor, the BREAKING banner, and adr-0087: not-required (no-migration-prescription) with the census.
    • "One rule" matches the predicate and its placement.
    • "Before and after" matches the composed pin.
    • "What still saves" matches body, body plus handler, and a malformed body (422).
    • "What is unchanged" matches as well: HookSchema, the artifact and boot doors, and os validate / os build.
    • "Rows stored before" matches the dev's one-off measurements: duplication reports the row failed, and migration leaves it.
    • "The fix" names the two real body languages.
  • Evidence:
    • The metadata-protocol suite passes 3468 of 3468, and typecheck exits 0 for both packages.
    • Composed and unit pins cover all three ruled pins.
    • Reverse verification, with a dist preflight: exactly pin 1's cases red, the controls green, and the restore proved by blob equality and an --absent preflight.
  • Gates: dispatch-gates --ran accounts for 64 of 64: 63 exit 0, and check:dual-build-cjs-loads NOT MEASURED (prerequisite not met), with a targeted CJS load of metadata-protocol read instead.
    • The artifact-roster block was run in full: 54 families, 51 exit 0 on the tree.
    • The 3 PR-context guards were re-run after pr_create and exit 0, including check-closing-target-claim.
  • CI: read by the seat at landing. The seat lands only once every check is green or an expected skip.

Out-of-scope, filed by the seat: a hook with neither body nor handler also answers 200 at this door and never runs. It is the same family, but a different shape, and its refusal needs fixture triage across five suites that use the bare shape as a schema-valid probe.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 06:17
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 06:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit ced217c Oct 4, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21658-meta-hook-handler-save branch October 4, 2026 06:53
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants