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

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