Skip to content

fix(runtime,cloud-connection)!: install-local refuses a hook with no body and a job body that does not bind, and withholds such a hook on rehydrate (#21585) - #21615

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21585-handler-hook-refusal
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21585-handler-hook-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21585

Clause-②: yes (narrowing)

Triage's retriage answer 5970803032 (Q1: A), re-claimed in 5970905860. This is #21489's ruling C applied to hooks and completed on rehydrate, with the off-spec job body folded in.

What changes

The install-local door (packages/cloud-connection, POST /api/v1/marketplace/install-local, os package install) refuses two more kinds of package it used to install with a 200. The refusal sits in the same step as the enabled-job refusal and gives the same response.

  • A hook with no body. A function-name handler names code that travels only in an artifact's runtime module, never in the JSON this door installs. So on this door such a hook could never bind to the package's own code. It used to install and then either never fire, or bind by name to a function the package does not ship.
    • Every hook is judged, since a hook has no on/off switch.
    • A hook carrying both body and handler installs, because its body wins, as in the binder.
  • An enabled job whose body does not bind. The door used to judge only that a job body was present. It now judges that the body binds, using the declaration's own parse of JobSchema.body.
    • An expression (L1) body, or one carrying body.timeoutMs, is refused instead of installing and never being scheduled.
  • One answer: 422 VALIDATION_ERROR, the code the job refusal already used.
    • It names everything the door cannot run: each hook and its handler, each job and its handler, and each refused job body with the key the declaration refuses.
    • The remedies are a body, or os start --artifact.
    • Nothing is registered, persisted, bound or scheduled.
    • os package install renders it unchanged, with no special case: Install failed (422 VALIDATION_ERROR): ….
    • No ledger edit: VALIDATION_ERROR fits under the ledger's admission rule for a generic validation condition. The reasoning is in the constant's doc.

The ONE binder (packages/runtime/src/app-artifact-handlers.ts) owns both judgements, so the door and the binder cannot disagree:

  • collectHooksWithoutBody: a hook whose body is not a body object. This mirrors the engine binder's own body-first test, so exactly the hooks whose handler the engine would resolve by name are named.
  • bindAppArtifactHandlers takes withholdHooksWithoutBody, which only install-local sets because it carries no runtime module.
    • Under it, a hook with no body is warned by name and not bound.
    • It fires only on the rehydrate of an entry an earlier build installed. The install route refuses such a hook first.
    • The result reports withheldHooks.
    • Install-local warns if the runtime it runs on predates the option.
  • collectJobsWithoutBody (the landed name, kept) also names an enabled job whose body does not bind: an expression body, or one carrying body.timeoutMs. Its TSDoc says so. It reads judgeJobBody (sandbox/body-runner.ts), a parse against JobSchema.shape.body.
    • The job body factory now binds by that same judgement, replacing its own parse with a separate timeoutMs check.
    • Its warn still says invalid job.body shape — the job is NOT scheduled, and now carries the declaration's sentence.
    • The export names collectJobsWithoutBody and JobWithoutBody are unchanged from main, because the shipped job liveness ledger anchors job/enabled's evidence on that symbol. This round's first head renamed them, and CI's @objectstack/spec repo tier caught it. The landed names are restored, with no alias and no packages/spec edit.

Docs: content/docs/automation/jobs.mdx now says the install door also refuses an enabled job whose body does not bind. No hand-written page states how a hook's handler behaves on os package install, so there was no hook sentence to correct.

Unchanged:

Measured before

These readings were taken on main 6c5697dffb through the public door, plus one unit reading on one engine, in os-dev report 5970542332.

  • A package with a hook in the deprecated handler form and no body installed with exit 0.
  • When the name resolved nowhere, the hook never fired.
  • When another app in the same runtime had registered a function under that name, the hook bound to that app's code, hot and after a restart.
  • An expression job body and a body.timeoutMs job body each installed with exit 0 and were never scheduled.
  • Only a server warn said any of this.

Pins

Pin (triage 5970803032) Where
The cross-app shape is refused at install with a non-zero exit packages/cli/test/package-install-local-hooks.integration.test.ts (exit 1, Install failed (422 VALIDATION_ERROR), names hook and remedy, nothing in the ledger); cloud-connection marketplace-install-local-hooks.test.ts (422, nothing registered, persisted or bound)
A pre-existing entry rehydrates with the hook unbound and warned the CLI integration pin, second boot over a ledger entry in an earlier build's layout: the record carries only the body hook's stamp, and the warn names the hook; cloud-connection rehydrate unit; runtime binder unit on a real ObjectQL engine (another app's function of the same name never runs)
The body-hook control installs and fires CLI integration (exit 0, stamp body); cloud-connection unit (bound under app:MANIFEST_ID)
An --artifact app's own handler hooks still bind CLI integration (the host artifact's own handler hook fires from its runtime module); runtime binder unit (no option: own function binds)
Each off-spec job-body shape is refused; a valid body job installs and runs CLI integration (expression body and body.timeoutMs each exit 1 naming the key, 0 rows; a valid body job exits 0 and writes rows); cloud-connection jobs unit; runtime collector unit, including that every job named is not scheduled and every enabled body job not named is

Reverse verification

Each leg went through scripts/ablation-replace.mjs in WRAP mode, with its restore trap held by the tool. Each was rebuilt, then checked with scripts/ablation-dist-preflight.mjs, then measured. Each restore was proven by blob equals HEAD and an empty git diff HEAD. Each was rebuilt again and checked with the preflight in the opposite mode, and finished with the whole tree clean. All were taken from committed e961c7f5d5, before the main merge.

Leg Anchor → mutation Blob dist preflight Went red Restore
(a) the hook refusal (door) if (unrunnable.jobs.length > 0 || unrunnable.hooks.length > 0) { → drops the hooks term 83b6de87a2b1 → 64f0fb4337e1 --absent 'unrunnable.hooks.length > 0': absent from all 6 built files (it was in pristine dist, 1 hit in index.js) cloud-connection hooks unit 2/6 (the two refusals); CLI integration 1/8 (the refusal; the CLI printed Package installed) blob 83b6de87a2b1 == HEAD, diff empty; rebuilt: marker back in 2 built files, tree clean
(b) the rehydrate withholding (binder) if (options.withholdHooksWithoutBody) { → … && String('ABLATED_21585_B') === '') { 8879aeae73fd → ee32244b3f9a marker present in dist/index.js and index.cjs runtime binder unit 1/9; cloud-connection rehydrate unit 1/6; CLI integration 2/8: the rehydrated record read hostbody, so the cross-app binding is back, and no warn appeared blob == HEAD, diff empty; rebuilt: marker absent from all 6, tree clean
(c) the job-body bindability check (collector) if (judged.binds) continue; → if (judged.binds || String('ABLATED_21585_C') !== '') continue; 8879aeae73fd → 9e40620a0d48 marker present in dist/index.js and index.cjs runtime collector unit 3/26; cloud-connection jobs unit 2/11; CLI integration 2/8 (both off-spec shapes installed) blob == HEAD, diff empty; rebuilt: marker absent from all 6, tree clean

The direction was as expected on every leg: red.

Verification (at c7144b68bc, after merging main 83b3d32020)

  • The test that went red in CI on 1b41b79d2d is pnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts: 42 passed. The whole pnpm --filter @objectstack/spec test:repo: 51 files, 879 passed.
  • pnpm --filter '@objectstack/cli...' build under os-verify-lock: VERDICT command-exit 0.
  • @objectstack/runtime: typecheck green, including check:test-typecheck. Full test: 318 files, 4495 passed, 19 skipped.
  • @objectstack/cloud-connection: typecheck green. Full test: 36 files, 437 passed.
  • @objectstack/cli, --project unit: 255 files, 3745 passed.
  • The install-local integration pins, on built packages:
    • package-install-local-{hooks,jobs}: 19 passed.
    • package-install-local-{handlers,boot-steps,uninstall-cleanups}: 40 passed.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 93 commands, all run, including the docs families the jobs.mdx edit brings in. --ran reports 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN.
    • check:skill-examples and check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, unrelated packages not yet built). Both exited 0 on rerun once a later gate in the run had built those packages. The record carries the reruns.
    • main moved to 5c9138b4b6 after this merge (objectql and driver-sql). The derivation did not call the tree stale, so the branch was not merged again.
  • Roster gates whose roster sits in a touched directory, all exit 0 on the first head: check:error-code-casing, check:route-ledger-census, check:authz-resolver, check:filter-alias-parity, check-changeset-fixed, check:engine-double-contract, check:error-status-conformance.
  • Full pnpm lint at c7144b68bc: exit 0.
  • The reverse-verification table above was taken on e961c7f5d5. The name restoration changes no logic, and the anchors it used are unchanged.

Acceptance notes

  • A hook whose body IS an object the hook body runner refuses is warned and not bound on every door. The binder does not fall back to its handler. The door does not refuse that shape: the ruling folded in job-body bindability only. Observation; carrier: none.
  • The rehydrate pin's ledger entry is written in the layout the install route persists, because no earlier build exists in the tree to write it.
  • The contract review of record is owed before enqueue (claim 5970905860).

Generated by Claude Code

claude added 5 commits October 3, 2026 16:21
…at do not bind

The one binder gains the judgements the install-local door refuses on:

- collectHooksWithoutBody names every hook whose code is only a
  function-name handler; a door that carries no runtime module passes
  withholdHooksWithoutBody, so such a hook is warned and not bound.
  A boot binds its own handler hooks unchanged.
- collectJobsWithoutRunnableBody (was collectJobsWithoutBody) also names
  an enabled job whose body the declaration refuses, through judgeJobBody,
  a parse against JobSchema.body that the job body factory now binds by too.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
… a job body that does not bind

The install door's refusal of code it cannot run now covers a hook with
no body and an enabled job whose body the declaration refuses, in one
422 VALIDATION_ERROR answer naming every item and its remedy. The door
reads the runtime binder's own judgements, and on a rehydrate tells the
binder this door carries no runtime module, so a hook with no body that
an older build installed is warned and not bound.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…spec job bodies at the public door

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…dies that do not bind

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cloud-connection, @objectstack/runtime, touching 32 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 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 JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))
  • content/docs/api/error-catalog.mdx (via VALIDATION_ERROR (literal, a string literal in JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))
  • content/docs/api/error-handling-client.mdx (via VALIDATION_ERROR (literal, a string literal in JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))
  • content/docs/api/error-handling-server.mdx (via VALIDATION_ERROR (literal, a string literal in JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))
  • content/docs/automation/jobs.mdx (via VALIDATION_ERROR (literal, a string literal in JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))
  • content/docs/automation/webhooks.mdx (via VALIDATION_ERROR (literal, a string literal in JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))
  • content/docs/data-modeling/drivers.mdx (via VALIDATION_ERROR (literal, a string literal in JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))
  • content/docs/deployment/cli.mdx (via MarketplaceInstallLocalPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/error-handling.mdx (via VALIDATION_ERROR (literal, a string literal in JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))
  • content/docs/protocol/objectql/types.mdx (via VALIDATION_ERROR (literal, a string literal in JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))
  • content/docs/ui/forms.mdx (via VALIDATION_ERROR (literal, a string literal in JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))

⛔ 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 JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))
  • content/docs/releases/v17/17-5.mdx (via VALIDATION_ERROR (literal, a string literal in JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))
  • content/docs/releases/v17/17-6.mdx (via VALIDATION_ERROR (literal, a string literal in JOB_WITHOUT_BODY_REFUSAL_CODE; a string literal in UNRUNNABLE_REFUSAL_CODE))

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
  • 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 28 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 83b3d32020a12f28e8dc8e71d5bbcc5769017b33 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from be4384f85ab28c42cf077bd8bd7b458b259d2967 — the merge of head c7144b68bcdadadac8d1e13aa8c818fdfd82fb8e into base 83b3d32020a12f28e8dc8e71d5bbcc5769017b33, 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 be4384f85ab28c42cf077bd8bd7b458b259d2967 && git checkout be4384f85ab28c42cf077bd8bd7b458b259d2967
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 83b3d32020a12f28e8dc8e71d5bbcc5769017b33 c7144b68bcdadadac8d1e13aa8c818fdfd82fb8e && git checkout -B drift-repro 83b3d32020a12f28e8dc8e71d5bbcc5769017b33 && git merge --no-ff c7144b68bcdadadac8d1e13aa8c818fdfd82fb8e

node scripts/docs-audit/affected-docs.mjs --json 83b3d32020a12f28e8dc8e71d5bbcc5769017b33

⚠️ 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 83b3d32020a12f28e8dc8e71d5bbcc5769017b33 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 3 commits October 3, 2026 17:50
…houtBody

The shipped job liveness ledger anchors job/enabled's evidence on
collectJobsWithoutBody, so the export keeps its name; its TSDoc now states
that the judgement also covers a body that does not bind (an expression
body, or one carrying body.timeoutMs).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ody that does not bind

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c7144b68bcdadadac8d1e13aa8c818fdfd82fb8e
Local-runs: none

Read on GitHub at 2026-10-03T18:58Z, after every check-run on the head had completed. Inputs: card #21585 (body and every comment, triage's grade 5969366675, the first claim and its release, the retriage answer 5970803032, both os-dev reports, the ACCEPT, the REWORK and the patch-round ACCEPT), PR #21615 (body, file list, the net diff against main at the merge base 83b3d32020), and the check-runs on the head. Check-runs, latest per name: every gate success; Console Pin Gate, Packed-tarball smoke (opt-in), and the second Auto Label / Check PR Size rows skipped (path- or event-filtered); none in_progress, none failed. Classes, doors and roles only below.

① Derived judgments

Accept-set changes the diff implies

  • A1 — install-local door (POST /api/v1/marketplace/install-local, os package install) refuses a package carrying a hook with no body object. 422 VALIDATION_ERROR, one answer naming each hook and the function its handler names, with the body remedy and the os start --artifact remedy. Answered at step 1c, ahead of the collision check, the posture gate, the hot-register and the ledger write, so nothing is registered, persisted, bound or scheduled. Right — triage's Q1-A (5970803032), ruling C applied to hooks: no silent drop, no warn-only.
  • A2 — the same door refuses an enabled job whose body does not bind. Judged by judgeJobBody, a parse of the present body against JobSchema.shape.body (the L2 script body with body.timeoutMs refused on a job), so an expression body or a body.timeoutMs body is refused with the key the declaration names. Right — the retriage's fold-in; the judgement is the declaration's own, not a paraphrase.
  • A3 — install-local rehydrate withholds a hook with no body from an entry an earlier build persisted. Warned by name, not bound; the body hooks beside it bind. Right — this is the measured difference from jobs (a rehydrated handler-only hook bound cross-app; a handler-only job never did), and the half of Q1-A that jobs' precedent could not supply.
  • A4 — every other door unchanged. The boot door (AppPlugin, os start --artifact, a defineStack config) passes no option and binds an app's own handler hooks as before (pinned on a real engine and at the public door). The metadata door is untouched: no packages/objectql file is in the diff. os validate is untouched. Right — triage's fence held; owner-scoped resolution stays the maintainer's [Decision] security(objectql): may a hook's handler name bind to a function another package registered (the engine-wide fallback HookSchema.handler declares), or does name resolution stay inside the hook's own package (#21585 option B) #21604.
  • A5 — a hook carrying both body and handler installs and binds its body. hookCarriesBody is body truthy and typeof body === 'object', the same test the engine binder's resolveHandler applies before it ever reads handler. Right — so the set the door refuses is exactly the set whose handler the engine would resolve by name, no wider and no narrower (a non-object body is named, as it should be).
  • A6 — a disabled job with an off-spec body still installs. enabled: false skips the judgement, as the binder never schedules it. Right — pinned on the door and on the collector.
  • A7 — version skew. A @objectstack/runtime that predates collectHooksWithoutBody or withholdHooksWithoutBody is detected at both the door's judgement and the bind result, and warns with the upgrade instruction instead of answering as if it had judged. Right — the precedent the [Decision] install-local: a package's declared jobs are never scheduled — refuse the install, name them in the install answer, or make job handlers declarable bodies (the jobs half of #21322) #21489 landing set for the job half; the two packages version in lockstep, so the skew is a mis-installed deployment, not a product state.

Public-surface changes the diff implies

  • P1 — @objectstack/runtime index: new export collectHooksWithoutBody, new type HookWithoutBody. Additive. Right.
  • P2 — AppArtifactHandlerBindingOptions.withholdHooksWithoutBody?: boolean. Optional; one caller sets it (marketplace-install-local-plugin.ts); app-plugin.ts passes none. Right.
  • P3 — AppArtifactHandlerBinding.withheldHooks: string[], required. Widening for every reader; the ONE producer (bindAppArtifactHandlers) always sets it; the door reads its absence as the skew signal (A7). Right — carried by the changeset's BREAKING banner and the Runtime bullet.
  • P4 — JobWithoutBody.bodyRefusal?: string. Optional. Right.
  • P5 — collectJobsWithoutBody now also names an enabled job whose body does not bind. The export name and the JobWithoutBody type are the landed names, restored after the REWORK: the shipped liveness ledger's job/enabled evidence anchors on app-artifact-handlers.ts#collectJobsWithoutBody, and its prose (enabled jobs only) still holds. Spec property liveness and every Test Core shard are success on this head. Right — and ⛔ no alias, no second name, no packages/spec edit, as the REWORK directed.
  • P6 — judgeJobBody / JobBodyJudgement in sandbox/body-runner.ts. Exported from the module file, NOT from the package index; @objectstack/runtime exposes the . subpath only. Not a public surface. Right — and placing it in the body runner is what makes the door's bindability check and jobBodyRunnerFactory one parse.
  • P7 — the job body factory's warn now carries the declaration's refusal sentence and its separate timeoutMs check is replaced by the same parse. A log line, not a contract. Right.
  • P8 — door-private renames (JOB_WITHOUT_BODY_REFUSAL_* to UNRUNNABLE_REFUSAL_*, the private jobsWithoutBody to unrunnableCode). Module-private. Right.
  • P9 — error code reused: VALIDATION_ERROR / 422, no ledger edit. The standard member for a generic validation condition under the ledger's admission rule; standardErrorCodeForHttpStatus(422) agrees; the code the job refusal already answers at this door. PLUGIN_MANIFEST_INVALID is correctly not it (a handler-form manifest is valid). check:error-status-conformance and check:error-code-casing reported green. Right.
  • P10 — the wire message. The job-without-body clause keeps the [Decision] install-local: a package's declared jobs are never scheduled — refuse the install, name them in the install answer, or make job handlers declarable bodies (the jobs half of #21322) #21489 sentence; the off-spec-body and hook clauses are appended; os package install renders it unchanged (Install failed (422 VALIDATION_ERROR): …, exit 1), pinned at the public door. Right.
  • P11 — docs. content/docs/automation/jobs.mdx's install-local sentence now states the bindability refusal and the valid body remedy. No hand-written page states a hook handler's install-local behaviour, so no hook sentence was owed; the drift check's rows are the VALIDATION_ERROR literal and the plugin class name, neither of which changed meaning. Right.
  • P12 — fences. No packages/objectql, no packages/spec, no error-code ledger, no scheduleAppArtifactJobs identity change ([finding] Two install-local packages that declare the same job name: the second install silently replaces the first package's job, which stops running, and the door says nothing #21602 is held behind this PR). Right.

Security posture (the brief's two questions)

  • Scoped to install-local only: the withholding option is set at exactly one call site, the install-local plugin's bind, which runs on install (where the refused set is already empty) and on rehydrate. The boot door passes nothing; the metadata door is outside the diff. So the measured cross-app code-binding path — a function-name handler on a door that carries no runtime module, resolving engine-wide by bare name — is closed on this door hot (A1) and after a restart (A3), and no other door's behaviour moves. Right, and exactly what Q1-A ruled.
  • One judgement: the door's collectHooksWithoutBody and the binder's withholding both go through hooksWithoutBodyOf(collectBundleHooks(bundle)) on the same hookCarriesBody predicate, which mirrors the engine's body-first test; the door's job refusal and the job body factory both go through judgeJobBody. The door and the binder cannot disagree about what this door can run. The runtime binder unit pins, on a real engine, that another app's same-named function never runs for a withheld hook; the public-door pin reads only the body hook's stamp after the restart, and the ablation of leg (b) brought the cross-app reading back. Right.

Pins against triage's five (5970803032): the cross-app shape refused at install with exit 1 — CLI integration and the cloud-connection hooks unit; a pre-existing entry rehydrating with the hook unbound and warned — CLI integration boot 2, the cloud-connection rehydrate unit, the runtime binder unit; the body-hook control installing and firing — CLI integration and unit; an --artifact app's own handler hooks still binding — CLI integration and the runtime unit; each off-spec job-body shape refused and a valid body job installing and running — CLI integration, the cloud-connection jobs unit, the runtime collector unit. All five present. Reverse verification: three legs (door refusal, rehydrate withholding, job-body bindability), each red where predicted, each restore blob- and dist-proven — as reported; not re-run here.

② Semver level

  • Changeset .changeset/21585-hook-refusal-install-local.md: @objectstack/runtime: minor, @objectstack/cloud-connection: minor. Both are published packages (17.6.0) whose published source the diff moves. The diff's only packages/cli file is a test, so no CLI bump is owed; the fixed group versions in lockstep regardless.
  • Clause-②: yes (narrowing) — line-initial in the PR body and carried in the changeset body. Right: the install-local door refuses packages it accepted before (A1, A2) and withholds on rehydrate what it bound before (A3). yes takes at least minor; (narrowing) is BREAKING; the launch-window guard refuses major, so minor is the ceiling and the correct level. The breaking-ness carriers are all present: the ! in the title, the **BREAKING** banner, and exactly one ADR-0087 marker, not-required (no-migration-prescription), whose argument holds on the diff — no authorable key, spelling or stored shape moves (HookSchema, JobSchema untouched; no spec edit), the packages publish, and no registry id covers a refused install. Check Changeset and Lint & Repo Gates are success on the head.
  • skip-changeset would be wrong here and is not claimed. The level, the arm and the carriers match what the diff publishes. Right.

③ Boundary flags

Implemented-by: claude/issue-21585-handler-hook-refusal
Reviewed-by: session_016GiHYRmLSNWTfbX9gVQkpz

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 19:01
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 19:01
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 045b946 Oct 3, 2026
47 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21585-handler-hook-refusal branch October 3, 2026 19:32
This was referenced Oct 3, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…mits that decided them (stage 11 of objectstack-ai#20595) (objectstack-ai#21617)

Part of objectstack-ai#20595
Clause-②: no

## What changed

Stage 11 of the `domain:engine` lane of the dead-citation sweep:
`packages/drivers/driver-mongodb/**`, comment and docblock prose only,
per the claim (`5971485908`). Stages 1 to 10 landed as `a7d9768ec`,
`d150c3039`, `4bf4e7e70`, `13a24ece2`, `db0cf2231`, `85986144c`,
`48fa7a381`, `c205b6c35`, `c98a72d69` and `fd5a1cd59`. objectstack-ai#20595 stays
open: the other half of this lane is the packages this stage does not
touch (`formula` 4 and `metadata-fs` 2 on the census after this stage, 6
in all), plus the test-string sites the card carries for a widened
stage.

Every comment or docblock site in the package that cited a tracker
number answering 404 is rewritten in ruling C+D's form C (record
`5749154545` on objectstack-ai#19123): the ADR when one records the decision,
otherwise the commit in this repository's history that made it. That is
**22 sites on 22 lines in 10 files, covering 7 numbers, plus one dead
comment id on one more line**:

- **9 census sites** (9 lines, 3 files under `src/`): the whole
`allocated-but-absent` population of the gate's own census in this
package at the base;
- **10 test-comment sites** (10 lines, 6 test files), which the census
defers. They carry 5 numbers: 3 the census itself reads as dead in this
package's `src` (objectstack-ai#11065, objectstack-ai#13195, objectstack-ai#14428) and 2 the census never judges
in this package (objectstack-ai#14434, and objectstack-ai#14917, which also stands on
`tsconfig.test.json`), which the board and a single read each settle;
- **3 sites outside the census glob, inside the claimed surface**: the
`//` comment lines of `tsconfig.test.json` (`:1` objectstack-ai#14917, `:3` objectstack-ai#14613,
`:25` objectstack-ai#14914), stage 8's precedent for the same file shape in
`packages/core`. `vitest.config.ts`, `tsconfig.json`, `package.json`,
`README.md` and `LICENSE` cite no dead number;
- **1 dead comment-id citation**:
`mongodb-11151-boolean-aggregand-answers.test.ts:13` named comment
`5448627494` on objectstack-ai#11152, which answers 404 although objectstack-ai#11152 itself
resolves (the card reports 24 comments and serves 16). Stage 4 rewrote
the same id in `driver-sql` as 「landed as commit f6fa22c」, and this
stage takes that wording.

**Anchors: 7 numbers and the comment id, all by commit; 0 by ADR, 0 by
repository qualifier; 7 distinct shas** (objectstack-ai#14428 and objectstack-ai#14914 share
`ca3fd4b1a`: the card's fix and that pull request's own squash). 5
numbers and the comment id reuse the anchor an earlier stage measured
for them; `a06faebbe` (objectstack-ai#14917) and the objectstack-ai#14914 reading of `ca3fd4b1a` are
measured here.

Only comments changed. Every file keeps its line count (23 lines out, 23
in, plus the changeset), so no line citation into any of them moves. No
code token moves (the guard below). All 46 changed lines open with a
comment marker. **No citation number is added**: on every changed line
the numbers on the new text are a subset of those on the old (the only
numbers on `+` lines are objectstack-ai#5286, objectstack-ai#13676 and objectstack-ai#14504 on
`tsconfig.test.json`, each already on its line and each answering 200).

**A `patch` changeset**: 2 of the 9 rewritten non-test lines are in the
published `dist` (the `MongoDBDriver.update()` docblock in the `.d.ts`
and the JavaScript, and one `//` line esbuild keeps), and `dist` is not
byte-identical with the base text (see Changeset).

## H0: the package and its size

The gate's own `node scripts/check-issue-citations.mjs --census --json`
at base `37442d475` (the before run below), `allocated-but-absent` per
remaining `domain:engine` package:

| package | before | after this stage |
|---|---|---|
| `drivers/driver-mongodb` | **9** | **0** |
| `formula` | 4 | 4 |
| `metadata-fs` | 2 | 2 |
| `drivers/driver-turso`, `metadata-core`, `core`, `metadata-protocol`,
`objectql`, `metadata`, `drivers/driver-sql`, `drivers/driver-memory`,
`drivers/driver-sqlite-wasm`, `plugins/plugin-pinyin-search`,
`platform-objects` | 0 each | 0 each |

The lane total goes 15 to 6. `driver-mongodb` reads 9, as at stage 10's
head census (`e89bd10cd`), so the stage went ahead.

## Census: `driver-mongodb`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count is its `allocated-but-absent` findings under
`packages/drivers/driver-mongodb/`.

| reading | tree | board | whole-repo `allocated-but-absent` | sites |
lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `37442d475`, run 17:13:20Z to 17:16:55Z | enumerated,
195 pages, frontier objectstack-ai#21613, 19,434 records | 128 | **9** | 9 | 3 | 3 |
| after | `ee337fc39`, run 17:27:02Z to 17:30:28Z | enumerated, 195
pages, frontier objectstack-ai#21614, 19,435 records (newest number read before the
run objectstack-ai#21613, after it objectstack-ai#21614) | 119 | **0** | 0 | 0 | 0 |

The whole-repo drop is 9, and the two finding sets differ by exactly the
9 rows of this package, removed; none was added. `resolves` (35,697),
`resolves-as-pull-request` (2,383) and `cross-repo-unjudged` (1,250) did
not move.

The head's later commits are the changeset and one merge of `main`. The
census was run a third time at the head `b69c176e9` (17:44:02Z to
17:47:23Z, 195 pages, frontier objectstack-ai#21615, 19,436 records, newest objectstack-ai#21615
before and after): whole-repo 119, `driver-mongodb` 0, and the finding
set is identical to the after run, line numbers included.

**Supplementary instrument, the whole package.** The census reads
neither test files nor strings nor files outside `src`. A second reading
runs the gate's own exported `extractCitations` (whole-file and
comment-prose projections) over every tracked file in the package (53)
and classifies each citation with the gate's `classifyCitation` against
one board enumerated by the gate's `enumerateBoard` (195 pages, frontier
objectstack-ai#21613, 19,434 records, read 17:17:48Z to 17:21:11Z), the same board for
both readings. Every one of the 7 numbers was then read on its own over
the issues endpoint (17:21:47Z): **all 7 answer 404**; the numbers that
stay on changed lines (objectstack-ai#5286, objectstack-ai#13676, objectstack-ai#14504) and the controls objectstack-ai#11152,
objectstack-ai#11249, objectstack-ai#11635, objectstack-ai#5346, objectstack-ai#13878, objectstack-ai#20399, objectstack-ai#15280 and objectstack-ai#12745 answer 200.

| reading | citations | dead | src comment | test comment |
`tsconfig.test.json` | test string | changelog |
|---|---|---|---|---|---|---|---|
| before, `37442d475` | 1,002 | **36** | 9 | 10 | 3 | 3 | 11 |
| after, `ee337fc39` | 980 | **14** | 0 | 0 | 0 | 3 | 11 |

The citation count drops by 22, the 22 rewritten tracker-number sites;
no respelling stays a citation. The live counts did not move (src
comment: 279 resolve, 12 as pull requests, 3 cross-repo; test comment:
264, 16 and 5; files outside `src`: 9 resolve). A third, raw reading
(every `#` followed by 2 to 6 digits, whatever surrounds it,
`CHANGELOG.md` aside) counts 700 before and 678 after: also a drop of
22.

**Comment ids.** Every ten-digit run under
`packages/drivers/driver-mongodb` (its `CHANGELOG.md` aside) was read:
five lines, four ids. `5448627494` answers 404 and is rewritten;
`5861435168` and `5865693155` (ruling records on objectstack-ai#20311 and objectstack-ai#20399,
`mongodb-filter.ts:696` to `:697`,
`mongodb-20444-empty-operator.test.ts:6`) and `5186668033`
(`mongodb-filter.ts:807`) answer 200; the control `5971485908`, the
claim, answers 200. After the rewrite the package carries four ten-digit
lines, all live. No `issuecomment` or `discussion_r` link stands in the
package (grep exit 1; the same grep finds them in `packages/runtime` and
`packages/spec`).

## Per-number table

`src` counts census sites, `test` the test-comment sites, `cfg` the
`tsconfig.test.json` comment lines. Every sha matches exactly one commit
(`git rev-parse --disambiguate`, count 1), is an ancestor of the base
`37442d475` and of `origin/main` `54521f08c` (`git merge-base
--is-ancestor`, exit 0 for all 7 on both; exit 0 is self-proving, and
the clone was unshallowed first), and names the number it replaces in
its message, its diff or both: 3 in the message and the diff (objectstack-ai#13195,
objectstack-ai#14613, objectstack-ai#14917), 3 in the diff alone (objectstack-ai#11065 for `20950404c`, objectstack-ai#14428 for
`ca3fd4b1a`, the comment id for `f6fa22ce1`), and 2 in the message alone
(objectstack-ai#14434 for `93940d492`, in the subject's squash suffix; objectstack-ai#14914 for
`ca3fd4b1a`, in the subject's squash suffix and a body line naming that
pull request's contract-review round), where the dead number was that
pull request's own and the commit is its squash. The `+` lines carry
exactly these 7 nine-hex spans as new ones. `git blame` at the base puts
10 of the 23 changed lines on their anchor; the other 13 were written by
a commit that cites the number as an earlier decision (`c4ecf0c49`
citing objectstack-ai#11065 three times; `df1812050` citing objectstack-ai#13195 five times;
`9268aec56`, which created
`mongodb-exists-has-value-translation.test.ts` as a measurement before
the ruling, citing objectstack-ai#13195 once; `ca3fd4b1a` citing objectstack-ai#14434 and objectstack-ai#14917;
`a06faebbe` citing objectstack-ai#14613 and objectstack-ai#14914 on the file it created), and in
each case the anchor is the commit that made the change the sentence
credits to the number. `source` says whether an earlier stage already
used this anchor for this number (`reused`) or it was measured here
(`measured`).

| number | src | test | cfg | anchor | kind | source | what it decided |
|---|---|---|---|---|---|---|---|
| `objectstack-ai#11065` | 2 | 1 | 0 | `20950404c` | commit | reused (stage 4, stage
5) | `driver-memory` counts a boolean aggregand as 1/0 in `avg` and
`sum` on both its faces; it wrote `numericAggregandExpr` in
`memory-analytics.ts`, the expression `mongodb-aggregation.ts:657` says
it reproduces (the squash of PR objectstack-ai#11153; its diff names objectstack-ai#11065 7 times) |
| `objectstack-ai#13195` | 6 | 5 | 0 | `9dac1ae01` | commit | reused (stage 5) |
`$exists` means HAS A VALUE on the live mingo path, the analytics face
and `translateFilter`; it wrote the `_presenceAnd` guard for `$exists`
alone, the `[objectstack-ai#13195] Value-independent` comment and the note that the
same clobber was reachable through `$null` and `$between`, the three
things the `mongodb-filter.ts` sites credit to the number (the squash of
PR objectstack-ai#13529) |
| `objectstack-ai#14428` | 1 | 2 | 0 | `ca3fd4b1a` | commit | reused (stage 10) |
`update()` on a missing id answers `null` on MongoDB and Turso's remote
face; it wrote the `MongoDBDriver.update()` docblock and created
`mongodb-update-missing-id.test.ts` |
| `objectstack-ai#14434` | 0 | 1 | 0 | `93940d492` | commit | reused (stage 4, stage
5, stage 10) | declare the not-found arm on `IDataDriver.update()` (that
pull request's squash); stage 10's 「Since objectstack-ai#13878 (commit 93940d4)」 for
the twin sentence |
| `objectstack-ai#14917` | 0 | 1 | 1 | `a06faebbe` | commit | measured | put the test
layer in front of tsc via the `objectstack-ai#5286` sibling route; it created this
package's `tsconfig.test.json` and graduated the `TEST_DEBT` entry (the
squash of PR objectstack-ai#15465) |
| `objectstack-ai#14613` | 0 | 0 | 1 | `81208086a` | commit | reused (stage 8) |
`@objectstack/core` declares a typecheck script and its test layer
enters the ratchet through a `tsconfig.test.json` sibling; stage 8 used
it for the identical sentence in `packages/core` |
| `objectstack-ai#14914` | 0 | 0 | 1 | `ca3fd4b1a` | commit | measured | that pull
request's own squash; its message records the three TS18047 errors the
widened `update()` declaration introduced and the type-check debt
ratchet catching them (10 to 13), the event `tsconfig.test.json:25`
describes |
| comment `5448627494` | 0 | 1 | 0 | `f6fa22ce1` | commit | reused
(stage 4) | the boolean aggregand column in the aggregation conformance
fixture with ruled numeric `min` / `max` on every face; its message
records the 2026-08-28 maintainer ruling (option A, superseding objectstack-ai#11249's
`false` / `true`), its diff names the id 3 times, and it wrote the line
|

No ADR or ruling record names any of the 7 numbers as the place their
decision is recorded.

## Wordings to check

Most rewrites swap a tag in place (`[#N]` to `[commit SHA]`, `(#N)` to
`(commit SHA)`, `#N's` to `commit SHA's`, stage 1's form). These say
more than the tag:

- **The dead comment id**: 「applied on that card's comment 5448627494,
ruling verbatim and」 became 「landed as commit f6fa22c, ruling verbatim
and」 (`mongodb-11151-boolean-aggregand-answers.test.ts:13`), stage 4's
wording for the same id and the same sentence in `driver-sql`, verbatim.
The quoted ruling 「12745 A回,其他同意。」 on the next line is untouched.
- **A dead pull-request number, in a possessive**: 「That is how CI
caught PR objectstack-ai#14914's / three TS18047 errors」 became 「That is how CI caught
commit ca3fd4b's / three TS18047 errors」 (`tsconfig.test.json:25`).
The errors arose on that pull request's branch and were narrowed before
it landed, so the squash as landed carries none of them; its message
records both the errors and the catch. This is stage 10's squash form
(「objectstack-ai#5181 (PR objectstack-ai#6076)」 to 「objectstack-ai#5181 (commit 6513c17)」). The alternative, if
the possessive reads wrong: 「caught, in the change commit ca3fd4b
landed, its」, one line, no reflow.
- **A filed defect**: 「That exclusion is itself a filed defect (objectstack-ai#14917),
not a design.」 became 「… a filed defect (closed by commit a06faeb),
not a design.」 (`mongodb-update-missing-id.test.ts:72`). The sentence
was written the day before the fix and describes the card; stage 5's
「CLOSED by commit 9dac1ae」 is the form for a card named as an open
defect.
- **A file header written before the ruling**:
`mongodb-exists-has-value-translation.test.ts:4` was written by
`9268aec56`, the measurement that pinned the divergence while 「the
direction stays undecided」; `9dac1ae01` inverted the file in place onto
the ruled answer. The header now opens 「[commit 9dac1ae]」, stage 5's
anchor for the twin header in `driver-memory`'s
`memory-exists-has-value-faces.test.ts`.
- **Sentence starts**: where the number opened a sentence, the new text
opens with 「Commit」: 「// Commit 9dac1ae landed this rule for `$exists`
alone」 (`mongodb-filter.ts:1476`). 「The commit 2095040 family shape」
(`mongodb-11151-boolean-aggregand-answers.test.ts:10`) is stage 4's 「the
settled commit 2095040 family shape」.
- **Ruling dates kept**: 「[objectstack-ai#13195, ruled 2026-08-30]」 became 「[commit
9dac1ae, ruled 2026-08-30]」 on two test lines, stage 5's form.
- No line was reflowed, so some are longer than their block's wrap
(`eslint.config.mjs` declares no line-length rule, and a reflow would
move neighbouring lines and every line citation into the file).

## Sites left

- **In comments (src, test, outside the glob): none.**
- **String literals: 3 test-string sites, 2 numbers, 3 files**: the
`describe` titles at `mongodb-exists-has-value-translation.test.ts:138`
(objectstack-ai#13195) and `mongodb-update-missing-id.test.ts:150` (objectstack-ai#14428), and the
`it` title at `mongodb-operator-key-clobber.test.ts:227` (objectstack-ai#13195). Both
numbers are in this stage's table. Strings are outside this stage's
surface; non-test strings cite none.
- **Outside `src`:** the release-owned `CHANGELOG.md` names dead numbers
on 11 sites (7 numbers); left.

## Mechanical guard: no code token moves

The guard compares base `37442d475` against the tree over all 10 touched
files, with TypeScript 6.0.3, to stages 2 to 10's two-reading
specification (their script was a scratch file and is gone, so it was
rewritten here to that specification and proven with the controls
below):

- **Reading 1**: the parser's leaf nodes, from a `forEachChild` walk.
Comments are trivia there, and JSDoc is never visited. A leaf that is
not itself a token is re-scanned with trivia skipped.
- **Reading 2**: the full token stream in parser context, from a
`getChildren` walk, JSDoc nodes skipped. String, template and numeric
literals are compared in full on both readings.
- **`tsconfig.test.json`**: reading 2 over `ts.parseJsonText`, and
reading 1 replaced by the parsed config object
(`ts.parseConfigFileTextToJson`) compared structurally.

Results, at `ee337fc39` (the later commits touch none of the 10 files):

- Real run: 20,837 base tokens, **0 files with a token change** (exit
0).
- Comment controls: 「Value-independent」 to 「VALUE-independent」
(`mongodb-filter.ts`) and 「it is the BUILD」 to 「it is the BUILd」
(`tsconfig.test.json`): 0 files changed (exit 0 each).
- Positive control, an identifier (`export class MongoDBDriver` to
`XMongoDBDriver`, `mongodb-driver.ts`): DIFFER on both readings (exit
1).
- Positive control, a string literal (the `describe` title 「… on a
missing id」 to 「… on a missing iD」,
`mongodb-update-missing-id.test.ts`): DIFFER on both readings (exit 1).
- Positive control, a template literal (`input: ` followed by the
`$${field}` template, to `$${field}x`, `mongodb-aggregation.ts`): DIFFER
on both readings (exit 1).
- Positive control, a numeric literal (`$lte: 100` to `101`,
`mongodb-filter.test.ts`): DIFFER on both readings (exit 1).
- Positive control, a config value (`"lib": ["ES2022"]` to `ES2023`,
`tsconfig.test.json`): DIFFER on both readings (exit 1).

Each mutation went through `scripts/ablation-replace.mjs` (wrap mode,
anchor hit 1 to 0, blob changed) under a shell trap that restores by
absolute path from `HEAD`. Each restore was proven equal to its `HEAD`
blob (`2c4ba4f8babb`, `aebae4859583`, `48ffb45e011f`, `4f12e29dbbce`,
`581fa7ca2401`, `162a1f72bae6`), with `git diff HEAD` empty and a clean
tree afterwards.

## Changeset: `patch` (`dist` measured)

`files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is
not private. In one script under the shared verify lock (VERDICT
command-exit 0, held 164s, shared-box seconds), at `ee337fc39`: the
dependency closure was built first (`pnpm --filter
'@objectstack/driver-mongodb^...' build`, exit 0), then the package's
own `build` (tsup and `check-dts-emitted`) ran three times, exit 0 each:

- **Leg 1**, the head text: 6 `dist` files hashed (`index.js`,
`index.mjs`, their sourcemaps, `index.d.ts`, `index.d.mts`). 2 of the 9
rewritten non-test lines appear verbatim in `dist`: the
`MongoDBDriver.update()` docblock line (`mongodb-driver.ts:432`) in all
four of `index.js`, `index.mjs`, `index.d.ts` and `index.d.mts`, and the
`// [commit 9dac1ae] Value-independent` line
(`mongodb-filter.ts:1265`) in `index.js` and `index.mjs`. The other 7
sit in comments the build drops.
- **Leg 2**, the base text put back in the 3 non-test touched files (3
of 3 proven equal to their base blob, written to the tree only): 4 of
the 6 files differ from leg 1 (`index.d.ts`, `index.d.mts`, `index.js`,
`index.mjs`); the two sourcemaps do not.
`scripts/ablation-dist-preflight.mjs` finds the base marker 「[objectstack-ai#14428] A
miss answers」 in those 4 built files (exit 0).
- **Leg 3**, after the proven restore (3 of 3 equal to their `HEAD`
blob, `git diff HEAD` empty, porcelain empty): all 6 files are
byte-identical to leg 1, and the preflight's `--absent` reading exits 0
with a clean tree, so the build is deterministic and the difference is
the rewrite.

So the rewrite ships, and
`.changeset/20595-driver-mongodb-provenance-anchors.md` declares a
`patch` for `@objectstack/driver-mongodb`, comment text only, with the
claim's `Clause-②: no` line. Every anchor is a commit, so it names no
ADR, repository qualifier or bracketed substitution; it says which
published files carry the reworded text, as measured above. The
changeset commit touches no file under
`packages/drivers/driver-mongodb`.

## Gates (head `b69c176e9`)

- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `b69c176e9` (11 paths against
merge base `54521f08c`) derived 62 commands. All 62 ran (17:43:06Z to
17:55:21Z, after the workspace build), each exit code captured before
any pipe: 62 exit 0. `--ran` reports 「62 derived, 62 run, 0
NOT-MEASURED, 0 UNRUN」 (a derived zero) and exits 0. The PM's lead
derivation (48 commands, tree `ec390ec00`, one path) is a subset: the
extra 14 are the eight families the `.changeset/` path adds (the
ADR-0087 registration and empty-changeset pairs,
`check:objectui-changeset`, `check:pm-changeset-deadline-census` and two
release self-tests), `check:type-check-coverage` and
`check:type-check-debt` (the `tsconfig.test.json` path), and four gates
whose sources name the touched driver files
(`check:engine-double-contract`, `check:objectql-double-limit`,
`check:query-options-erasure`, `check:where-matcher`).
- **Named readings:** `node scripts/check-issue-citations.mjs` exits 0
(「no issue citations added against 54521f0」: the `+` lines in the 3
non-test source files carry no number); `pnpm check:issue-citations`
exits 0 (its self-test); `pnpm check:doc-authoring` exits 0 (the
sibling-package prose-id baseline holds, no growth); `pnpm
check:nul-bytes` exits 0 (9,980 files, no raw control bytes), and a
control-byte grep over the 11 changed files finds none (exit 1). The
changeset gates exit 0: `check-adr-0087-registration` (「1 non-breaking
changeset(s) seen」), `check-empty-changeset` (「1 declaring changeset(s)
added」), `check-changeset-no-major` (「no `major` bump」; its Clause-②
level axis reads the pull request body, so it is not applicable to a
local run and is CI's reading), and `check:changeset-gate-self-tests`.
`check:type-check-coverage` and `check:type-check-debt` exit 0 (the debt
re-measure: every entry at its measurement).

- **Build, tests and typecheck, under the verify lock** (VERDICT
command-exit 0, held 225s, shared-box seconds), at `b69c176e9`: the
workspace build (`turbo run build --filter='./packages/*'
--filter='./packages/*/*' --concurrency=2`: 71 of 71 tasks, 17 cached),
then `pnpm --filter @objectstack/driver-mongodb test`: 31 test files
pass and 5 are skipped (36), 690 tests pass and 182 are skipped (872);
the skips are the suites that need a `mongod` binary, which run only on
opt-in. `pnpm --filter @objectstack/driver-mongodb typecheck` (`tsc
--noEmit` and `check:test-typecheck` over `tsconfig.test.json`) exits 0.
`tsc --listFilesOnly` puts all 36 tracked test files in
`tsconfig.test.json`'s program, and each of the 9 changed `.ts` files in
a program (the 3 non-test ones in both). No importing package owes a
run: the declaration files change only in comment text.
- **Lint, as a proven narrowing, at `b69c176e9`:** eslint with inline
config disabled, over the 9 touched `.ts` files plus `dist/index.js` as
the control and `tsconfig.test.json`: 11 results, 0 errors and 2
warnings, the control's ignore notice and 「no matching configuration」
for `tsconfig.test.json` (eslint's files patterns never name `.json`, so
that file is outside its population); none of the 9 is reported ignored.
`eslint.config.mjs` never enables type-aware linting (its lines 327 and
328 say so), so a comment edit cannot move the verdict on an untouched
file. The repo-wide `pnpm lint` is CI's run.

## Acceptance notes

- **Base and merge.** The dispatch read `origin/main` at `ec390ec00`; by
the time the worktree was cut, `main` had moved one commit (`37442d475`,
a release-workflow fix touching none of this package,
`check-issue-citations.mjs` or `dispatch-gates.mjs`), and the branch was
cut there. The clone was shallow (two shallow roots, 1,215 commits
reachable) and held none of the anchors, so it was unshallowed (`git
fetch --unshallow origin main`, 15,634 commits) before any blame,
ancestry or history reading. The branch merges `main` once, pinned to
`54521f08c` (merge `b69c176e9`, no conflict, no deferred regeneration).
The two commits it brought (`0721848b8`, `spec`; `54521f08c`, a QA
checklist item) touch neither `packages/drivers/driver-mongodb`,
`check-issue-citations.mjs` nor `dispatch-gates.mjs`; the workspace was
rebuilt after the merge, before the tests and gates. The net diff
against `main` is the 10 rewritten files (+23/−23) and the changeset
(+15).
- **The same dead numbers outside this package**, each left to its own
carrier: `driver-mongodb`'s 3 test-string sites (above); `CHANGELOG.md`
(release-owned).
- **Wording only:** no line without a number was changed.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… own package only (objectstack-ai#21604) (objectstack-ai#21653)

Fixes objectstack-ai#21604
Clause-②: yes (narrowing)

Executes the maintainer's ruling on objectstack-ai#21604 (comment 5974477722, letter
B, 「同意」 2026-10-03T23:11Z): **a hook's `handler` name resolves inside
the hook's own package only.** The functions the package's own runtime
module registers keep resolving; a name the package does not hold is
refused at registration, with a refusal that names it;
`HookSchema.handler`'s declaration changes in the same PR. objectstack-ai#21585's
landed install-local refusal (objectstack-ai#21615, `045b946256`) is untouched: no
file of `packages/runtime` or `packages/cloud-connection` source changes
here.

## Census first (the ruling's first step): zero dependents

Every composition that could rely on cross-package resolution by name,
read before any refusal was written:

| Composition | Tree | What was read | Dependents |
|:--|:--|:--|:--|
| objectstack `examples/**` | objectstack `15fe567c9c` | string
`handler:` values, `registerFunction` calls, `functions:` declarations,
`composeStacks`, each app's hook forms | **0.** The only string
`handler` is a job's (app-showcase `sweepProjectHealth`), which the job
half resolves against its own bundle. app-showcase's 5 hooks all carry
`body`; app-crm and app-todo each have 1 inline-function hook, which `os
build` lowers to the hook's own name inside that app's own runtime
module (same owner). app-multi-package and embed-objectql declare no
hooks or functions. |
| this repository's `--artifact` runtime modules | objectstack
`15fe567c9c` | tracked `objectstack-runtime*.mjs`; tracked non-TS files
naming `runtimeModule` | **0 committed.** A runtime module is a build
output of an app's own config. |
| hotcrm | `f24c196588` | the same greps | **0.** No string `handler:`,
no `registerFunction`. Its 19 hook files are inline functions inside one
`composeStacks` app (one owner), each lowered to its own name. |
| objectos | `7612ffebd1` | the same greps | **0.** No hit for any of
them. |
| cloud | none | | **NOT MEASURED.** Unreachable from this session: a
shallow clone has no credentials, a REST read answers 403 "not enabled
for this session", and `add_repo` answers no access. |

No stop condition fired: no real dependent was found, and owner-scoped
resolution needed no new authorable spelling (see boundary flag 9).

## What changed, and where

- `packages/objectql/src/hook-binder.ts`: `resolveHandler` resolves a
string `handler` against the functions handed to the bind (the package's
`functions`, which an `--artifact` runtime module supplies), then
against the engine entry of that name **only if the entry's `packageId`
equals the bind's `packageId`** (`ownPackageFunction`, reading the owner
through the existing `resolveFunctionEntry`). A string handler that
resolves to neither is refused at registration: an `Error` carrying
`code: 'INVALID_REFERENCE'`, `status: 400`, `hook`, `handler` and
`packageId`, recorded on `BindHooksResult.errors[]` (which gains
optional `code` and `status`), logged at `error` with the `Error` in the
logger's error slot, and thrown under `strict`. The hook is not bound.
- `packages/objectql/src/engine.ts`: doc comments only (the registry and
`registerFunction`). The lookup itself is unchanged: the entry already
carried its owner.
- `packages/spec/src/data/hook.zod.ts`: `HookSchema.handler`'s TSDoc
stops declaring the engine-wide fallback ("anything
`engine.registerFunction(name, fn)` added") and states the own-package
rule, the refusal, and the route for a runtime-authored hook. The schema
and its `.describe()` are unchanged, so no generated artifact moves
(`check:generated`: all 15 up to date).
- The landing matches the dispatch's expected surface; no producer
elsewhere needed the fix.

## ① The accept set, before and after

A hook whose `handler` is a function name and which has no `body` (a
hook with a `body` binds exactly as before, body first):

| Door | Before | After |
|:--|:--|:--|
| Boot of a code package (`AppPlugin`, a `defineStack` config, `os start
--artifact`) | its own `functions` (runtime module included), then any
function any package registered | its own `functions` (runtime module
included), then functions its own package registered earlier; **another
app's function is refused** |
| Install-local (`os package install`) | handler-only hooks already
refused at install and withheld on rehydrate | unchanged |
| Metadata door (`PUT /api/v1/meta/hook/NAME`, bound under owner
`metadata-service`) | any function any package registered | **none**:
the owner registers no functions, so every handler-only authored hook is
refused when the door binds it |
| Multi-app composition (several apps on one engine) | app Y's hook
could bind app X's function | **refused** |
| Direct `bindHooksToEngine` with no `packageId` | any engine function |
only the functions handed to that bind |
| A name no package holds (typo) | skipped, `warn`, reason `unknown
function 'NAME'` | refused: same envelope as above, `error` |

## ② Semver

`minor` for `@objectstack/objectql` and `@objectstack/spec`,
**BREAKING**, `!` in the title, `Clause-②: yes (narrowing)`, under the
launch-window convention for narrowings of an accept set. The changeset
(`.changeset/21604-hook-handler-package-scope.md`) carries the ADR-0087
disposition `not-required (no-migration-prescription)`, written from the
census facts above: no authorable key, spelling, export of a published
release or stored shape moves, so `objectstack migrate meta` has nothing
to rewrite. (The marker sits in the changeset as the gate's comment-form
marker; `check-adr-0087-registration --base origin/main` reads it
green.)

## ③ Boundary flags

1. **Log level and text.** An unresolved string handler used to log
`warn` with reason `unknown function 'NAME'`; it now logs `error` with
the coded refusal's sentence, beside the binder's other coded
registration refusal (the stored-metadata body boundary, also logged at
`error` by this binder). The ruling asks for a loud refusal at
registration.
2. **Result type.** `BindHooksResult.errors[]` gains optional `code` and
`status` (additive output). `HOOK_HANDLER_NOT_IN_PACKAGE_CODE` and
`HOOK_HANDLER_NOT_IN_PACKAGE_STATUS` are exported from `hook-binder.ts`
only; neither `index.ts` nor `core.ts` re-exports them, so the package's
public entry gains no symbol.
3. **The envelope (H4).** No coded refusal existed for this condition
(the binder's unresolved branch carried a bare reason string).
`INVALID_REFERENCE` / 400 is the standard catalog's member for a
reference that does not resolve where it must. The ledger's admission
rule sends a generic condition to the standard catalog, so no code is
registered; `plugin-auth` already answers `INVALID_REFERENCE` for both a
missing and a cross-scope reference. The sibling registration refusal's
`PERMISSION_DENIED` / 403 was not reused: that refusal is about a
permission on a table; this one is about a name that does not resolve,
and a typo is no permission question.
4. **`strict`** (`OBJECTQL_STRICT_HOOKS=1`) throws the refusal. A strict
runtime whose hook bound across packages now fails that bind, exactly as
it already failed an unknown name.
5. **The metadata door (H2).** A runtime-authored hook is bound under
the synthetic owner `metadata-service`, which registers no function, so
a handler-only authored hook is always refused at bind. The save itself
still answers as before: the pin records `200` for that `PUT`. That is
the same posture as the stored-metadata body boundary, which refuses at
bind and leaves the save door's answer unchanged. A `sys_metadata` hook
row stamped with a `package_id` (the Studio package authoring workspace)
is still bound under `metadata-service`, so it does not reach that
package's runtime-module functions; before, it reached every function.
Measured pull: zero string handlers anywhere in the census. Resolving by
a row's own `package_id` would let any metadata author claim a package's
code, which is the channel the ruling closes.
6. **A bind that names no package (H2).** With no `packageId`, a hook
resolves only the functions handed to that bind; an engine entry
registered without an owner is resolvable by no hook. Measured: every
first-party door stamps an owner (`app:APPID`, `metadata-service`,
`sys:audit`). After a platform boot (ObjectQL, sqlite-wasm, Hono, one
app, platform objects, auth, security, sharing, REST, dispatcher), the
engine's function registry holds exactly one entry, the app's own
(`h3_fn`, owner `app:com.h3.probe`). I read "a name the package does not
hold is refused" as covering a bind with no package; the reviewer may
weigh that reading.
7. **The platform's own functions (H3).** None reach the engine
registry. The formula stdlib's `registerFunction` registers into a
`cel-js` `Environment`, not the engine
(`packages/formula/src/stdlib.ts`). So no platform function's resolution
changes; H3's "formula stdlib" leg is falsified.
8. **One artifact, one owner.** A multi-package artifact (`packages[]`,
`composeStacks`) is bound under one owner `app:APPID`, with its
functions flattened, so a hook in one composed package can still name a
sibling package's function inside the same artifact. Census:
app-multi-package declares no hooks or functions, and hotcrm's
composition lowers each hook to its own name. Scoping inside one
artifact would need per-package attribution in the bundle collectors,
beyond "only as far as owner-scoped resolution needs it".
9. **No new spelling.** "Cross-package reuse must name the owning
package explicitly" is met by an existing spelling: import the function
from the package that owns it and declare it in your own `functions`.
The refusal and the changeset prescribe exactly that, and no `pkg/fn` or
`{ package, name }` form was minted.
10. **Install-local** is untouched. Its CLI integration pin
(`packages/cli/test/package-install-local-hooks.integration.test.ts`,
whose host hook names its own runtime-module function) is in the CLI
integration tier and is declared to CI; this diff touches no CLI file.

## Pins

- `packages/objectql/src/hook-binder-package-scope.test.ts`. Refusals,
each asserting `code` `INVALID_REFERENCE`, `status` 400 and that the
hook did not bind (the other package's function never runs): another
package's function; the same under `strict` (thrown, with `hook`,
`handler` and `packageId`); a name nobody holds; the metadata-door
owner, read off the engine logger's `error` call; a bind with no package
naming an unowned entry. Controls: a function handed to the hook's own
bind; a function its own package registered in an earlier bind.
- `packages/runtime/src/hook-handler-package-scope.pin.test.ts`, a
composed kernel. ① Multi-app composition: app Y's hook naming app X's
`x_stamp` is refused, and Y's insert is not stamped by X. ② Metadata
door: `PUT /api/v1/meta/hook/scope_authored_cross` naming `x_stamp` is
refused when the door binds it, while an authored `body` hook (the
re-sync witness) fires. Controls: X's own hook binds and runs; app Z,
loaded through `loadArtifactBundle` from an artifact whose runtime
module exports `z_stamp`, binds and runs.
- Re-triaged fixtures in `hook-binder.test.ts`: the two cases that
pinned the text `unknown function` (the refused branch) now assert the
envelope.

## Reverse verification (committed first, at `1eb671bac6`)

The owner check was ablated through `scripts/ablation-replace.mjs` in
WRAP mode, with an absolute-path `git checkout HEAD -- PATH` trap. The
ablated `ownPackageFunction` resolves any entry by name, which is the
old fallback. On-disk proof: anchor 1 → 0, replacement 0 → 1, blob
`9301e0130c` → `49bf4c96cc`. `pnpm --filter @objectstack/objectql build`
exited 0, and `ablation-dist-preflight` found the marker in all 4 JS
files the runtime suite consumes.
- objectql pins: **4 red** (another package's function, `strict`,
metadata-door owner, unowned bind) and **30 green** (the typo refusal,
both controls, the existing binder suite).
- runtime composed pin: **2 red**, with the defect itself as the reason:
Y's insert came back `|x-fn`, and the authored row came back
`|x-fn|authored-body|x-fn`. **2 controls green.**
- Restore: blob back to the `HEAD` blob `9301e0130c`, `git diff HEAD`
empty, whole-tree `git status --porcelain` empty. After the rebuild, the
marker is absent from all 14 `dist/` files and the pins are green again
(34/34 and 4/4).
- A first ablation run read the same red and green split, but its DTS
step failed on the then-unused `packageId` parameter (the JS bundles
still carried the marker). It was rerun with `void packageId;` so the
build leg exits 0, and the figures above are from that clean run.

## Tests

Suites at `1eb671bac6`; the later merges of `origin/main` (`b43c6fe76f`,
`308ae946b9`) bring only service-analytics and CLI files, with no
overlap. Build order: `turbo build --filter='@objectstack/runtime^...'`,
then `--filter='@objectstack/dogfood^...' --filter=@objectstack/rest
--filter=@objectstack/service-automation`, after the objectql change.
- `@objectstack/objectql`: `local` project 370 files / 7441 passed;
`repo` 1 / 5 passed; `typecheck` green (test layer within its pinned
debt).
- `@objectstack/runtime` (reads objectql's `dist/`): `local` 319 files /
4534 passed, 19 skipped; `repo` 3 / 751 passed; `typecheck` green.
- `@objectstack/rest`: `local` 260 files / 4897 passed, 326 skipped;
`repo` 5 / 177 passed, 1 skipped.
- `@objectstack/service-automation`: 168 files / 2078 passed.
- dogfood hook files (`hook-error-format`,
`hook-refusal-user-facing-marking`, `hook-runas-fls`,
`webhook-materialization`): 4 files / 13 passed.
- `@objectstack/spec`: `check:generated`, all 15 artifacts up to date
against a `dist/` whose declaration stamp matches.

Direction: these are downstream consumers of objectql (runtime, rest,
service-automation, dogfood); the spec edit is TSDoc only.

## Gates (at `308ae946b9`)

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, with no paths, derived 91 commands. That is
the dispatch list plus `check-empty-changeset` (both),
`release-rehearsal-clone --self-test`, `release-pending-publish
--self-test`, `check:engine-double-contract`,
`check:objectql-double-limit`, `check:objectui-changeset`,
`check:pm-changeset-deadline-census`, `check:query-options-erasure`,
`check:stack-collection-maps`, `check:swallow-census-controls`,
`check:type-check-coverage`, `check:type-check-debt` and
`check:where-matcher`. All 91 ran, each exit code captured before any
pipe, and all 91 exited 0. `--ran` reconciliation: 91 derived, 91 run, 0
NOT-MEASURED, 0 UNRUN. On the first pass, `check:dual-build-cjs-loads`
answered PREREQUISITE NOT MET: 8 packages unrelated to this diff had no
`dist/` in this worktree. Those were built, and it measured green.

Lint, narrowed and proven: `eslint --no-inline-config --format json`
over the 6 touched TS files reports 6 files linted, 0 errors and 0
warnings. That covers every TS file in the diff under the config's
`**/*.ts` and `packages/**` globs. `eslint.config.mjs` never enables
type-aware linting (no `parserOptions.project`, no typed rules), so the
diff cannot move any untouched file's verdict. The repo-wide `pnpm lint`
is CI's.

## NOT MEASURED

- The cloud census: unreachable, as above.
- CI-only families the derivation names, which have no local invocation:
Test Core shards, Dogfood Regression Gate, Dogfood Verify CLI, Build
Core, Temporal Conformance, and the workspace type-check lanes.
- The CLI integration tier: declared to CI.
- `check:objectui-pin-citations`: its self-test's live objectui round
trip was skipped, because there is no objectui checkout here; the gate
itself passed.

## Acceptance notes (observed, not filed)

- `Action.target` and a flow `script` node's `config.function` still
resolve through the engine's function registry by bare name
(`service-automation` bridges `objectql.resolveFunction`). The ruling
covers a hook's `handler` only. This is the same family on other
surfaces, recorded from a code-read with no measured reach.
- The registry stays keyed by bare name: two packages registering one
name leave the later one's entry. A hook bound in the same call resolves
its own bundle first, so boot binding is unaffected.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…whose pull does not bind (objectstack-ai#21672) (objectstack-ai#21683)

Fixes objectstack-ai#21672

Clause-②: yes (narrowing)

Built to triage `5975994778` (direction) and `5976336256` (unlocked once
PR objectstack-ai#21668 landed as `909229e976`), dispatched under claim `5976423110`.
This is PR objectstack-ai#21615's shape: one pull clause in the existing unrunnable
judgement, read from the same `judgeJobPull` the binder schedules by.
There is no second judge.

## What changes

**The install-local door (`packages/cloud-connection`, `POST
/api/v1/marketplace/install-local`, `os package install`)** now refuses
a package whose enabled job declares a `pull` that does not bind. It
used to install it with a 200, and the binder then warned and never
scheduled the job.

- "Does not bind" is exactly `judgeJobPull`'s answer: the `pull` names a
mapping the package does not declare, or a mapping with no
`connectorSource`, or the job declares `body` or `handler` beside the
`pull`.
- **One answer:** `422 VALIDATION_ERROR`, the code and status the door
already gives a job `body` that does not bind. No new error code.
- `describeUnrunnable` gains a pull clause beside the body clauses. It
names each such job with the refusal `judgeJobPull` gives
(`pull.mapping: …`), and the remedy: declare the mapping with a
`connectorSource`, or correct the `pull`. `os validate` refuses the same
`pull`.
- A pull job is never described as a job with no `body`: that would send
the author to write a `body` beside the `pull`, the shape the
declaration refuses.
- Nothing is registered, persisted or scheduled. `os package install`
exits 1 and prints `Install failed (422 VALIDATION_ERROR)`.
- A **disabled** pull job does not block its install, as a disabled body
job does not.
- A pull job naming a declared mapping with a `connectorSource` installs
and is scheduled, as before.

**The ONE binder (`packages/runtime/src/app-artifact-handlers.ts`).**

- `collectJobsWithoutBody` (the landed name, kept:
`packages/spec/liveness/job.json` anchors on it) now judges a job that
declares `pull` by calling `judgeJobPull(job, bundle)`, the function the
binder calls before it schedules a pull job. A pull that binds is not
named. A pull that does not bind is named with its refusal.
- `JobWithoutBody` gains one optional field, **`pullRefusal`**: the
refusal `judgeJobPull` gives. A job carrying it carries no
`bodyRefusal`, since a `pull` is judged before any `body` beside it, as
in the binder.
- The binder itself is unchanged. Its TSDoc now says install-local
refuses the shape up front, so on that door the binder's pull warn fires
only on a rehydrate.

**Docs:** `content/docs/automation/jobs.mdx` now says the install door
refuses an enabled job whose `pull` does not bind. That replaces "A
`pull` job is data too, and is not refused". It also says what happens
on rehydrate.

**Unchanged:** `packages/spec`, `service-automation` and `objectql` are
untouched. So are `JobSchema`, `MappingSchema`, the binder's scheduling,
the boot door and the error-code ledger.

## A pending release note this change makes false, corrected here
(confirmation requested)

`.changeset/20281-job-pull-organization.md` (PR objectstack-ai#21668, not yet
released) says:

> `collectJobsWithoutBody` no longer names a `pull` job, so `os package
install` does not refuse one.

This PR makes that false. It now reads:

> `collectJobsWithoutBody` does not name a `pull` job that binds, so `os
package install` installs one.

Nothing else in that note changed. `check-empty-changeset` names this
case its DELIBERATE CORRECTION class, so **`Check Changeset` stays red
on this PR by design**. That context is not required. Its own text asks
for the correction to be confirmed in writing on the PR, and ⛔ never
`skip-changeset`. Restoring the note from the base would ship the false
sentence in the same release as this PR's own changeset.

## Measured before (A1), at the public door, on `origin/main`
`eed2dee481`

Measured with the new integration file below against unmodified runtime
and cloud-connection `dist/`, as part of the CLI's dependency closure
built at `eed2dee481`:

- A pull job naming an undeclared mapping (`orders_pul`) installed with
exit 0: `Package installed into the running kernel`.
- The server said, at `WARN`: `[MarketplaceInstallLocal] job pull does
not bind — the job is NOT scheduled: pull.mapping: this artifact
declares no mapping 'orders_pul' — …`, with
`{"appId":"com.example.pullmissing","job":"pull_missing_orders"}` on the
line.
- A pull job whose mapping has no `connectorSource` installed the same
way, and the warn said `pull.mapping: mapping 'orders_pull' declares no
connectorSource, so there is nothing to pull — …`.
- Both packages were in the install-local ledger, and neither job was
ever scheduled (no `sys_job` row).
- The file's three refusal pins went red and its five controls went
green.

## One judge (A2)

- The collector calls `judgeJobPull(job, bundle)`. That is the same
function, with the same arguments, that `scheduleAppArtifactJobs` calls
before it schedules a pull job. The door only formats the `pullRefusal`
it is handed, and does not re-judge or paraphrase the question.
- Pinned in the runtime unit: on one bundle, every pull job the
collector names is one the binder does not schedule. Every enabled pull
job it does not name, the binder schedules. Each named job's
`pullRefusal` is the exact tail of the warn the binder logs when it
withholds that job.
- `collectJobsWithoutBody` and `JobWithoutBody` are not renamed.
`packages/spec/liveness/job.json` anchors `job/enabled` on the collector
and the `pull` row on `judgeJobPull`. Both rows are unchanged, and
`scripts/liveness/evidence.test.ts` passes (42).

## Rehydrate (A4): it already held, so it is pinned, not coded

On `origin/main` the binder's existing skip already withheld a
non-binding pull job of a persisted entry and warned with the job's name
in the line's meta. The rehydrate pins in the integration file were
green before the fix and stay green after it. No rehydrate code was
added.

## Pins

| Pin | Where |
|:---|:---|
| An undeclared mapping is refused at the public door |
`packages/cli/test/package-install-local-jobs-pull.integration.test.ts`:
exit 1, `Install failed (422 VALIDATION_ERROR)`, names the job,
`pull.mapping: …` and `os validate`; not in the ledger, no `sys_job`
row. `cloud-connection` `marketplace-install-local-jobs.test.ts`: 422,
nothing registered, persisted or scheduled (not even a valid body job
beside it), and the no-`body` clause is not used. |
| A mapping with no `connectorSource` is refused the same way | CLI
integration (exit 1, names the job and the reason); cloud-connection
unit |
| One answer names every kind | cloud-connection unit: a handler-only
job and an unbindable pull job in one 422 |
| Control: a declared mapping installs and is scheduled | CLI
integration: exit 0, its `sys_job` row, and a `sys_job_run` row per run.
Each run reaches the automation service's pull door, which records
`failed` because the package declares no `connectors[]` entry. That is
the run's verdict, not the install's. cloud-connection unit: 200,
scheduled, and a run calls `pullConnectorSource` with the mapping. |
| A disabled unbindable pull job installs | CLI integration (exit 0, no
`sys_job` row); cloud-connection unit |
| Rehydrate of an entry an earlier build persisted withholds the job |
CLI integration, second boot over a ledger entry: the bindable pull job
of the entry is scheduled and runs, the unbindable one has no `sys_job`
or `sys_job_run` row, and a `WARN` line names it with `pull.mapping: …`.
cloud-connection rehydrate unit: the same, with the warn's `job` meta. |
| One judge | runtime `app-artifact-handlers.job-pull.test.ts` (the two
pins above) |

The runtime pin that asserted the old behaviour, `collectJobsWithoutBody
never names a pull job`, is replaced by the two collector pins above.

## Reverse verification (A5)

One leg went through `scripts/ablation-replace.mjs` in WRAP mode, with
its restore trap held by the tool. It was rebuilt, checked with
`scripts/ablation-dist-preflight.mjs`, then measured. The leg was taken
on committed `413869dfe1`.

The door's acceptance condition has no pull-specific term: it refuses on
`unrunnable.jobs.length`. So the door's pull clause, as a judgement, is
the collector's pull leg, and that is what was ablated. Ablating
`describeUnrunnable`'s sentence alone would leave the 422 standing and
change only prose.

| Leg | Anchor → mutation | Blob | dist preflight | Went red | Stayed
green | Restore |
|:---|:---|:---|:---|:---|:---|:---|
| The collector's pull leg (the door's pull judgement) | `if
(judged.binds) continue;` + newline + `pullRefusal = judged.refusal;` →
the same with `\|\| String('ABLATED_21672_PULL') !== ''` added to the
condition, so every pull job is skipped, as on `main` | `d207bdb16564` →
`f4e6ffa8924b` | marker present in `dist/index.js` and `index.cjs` |
runtime collector unit 2/21. cloud-connection jobs unit 3/17: both
refusals and the one-answer pin. CLI integration 3/8: both refusals, CLI
printed `Package installed`, and the ledger pin. | the declared-mapping
control, the disabled pull job, and the rehydrate pins (all three
layers) | the tool: blob `d207bdb16564` == HEAD, `git diff HEAD` empty.
Rebuilt, then `--absent`: marker absent from all 6 built files, tree
clean. |

The direction was red, as expected. The tool refused a first attempt
before running anything: that replacement still contained the anchor, so
the anchor count could not drop. It restored the file and nothing was
measured.

## Verification (at `413869dfe1`)

All runs are at `413869dfe1`, the final commit, with build and test runs
under `os-verify-lock`. `origin/main` has since moved one commit, to
`7d0781482d`. That commit touches only
`.claude/skills/pm-dispatch/references/execution-duties.md`, so this
branch was not merged again.

- `@objectstack/runtime`: `typecheck` green, including
`check:test-typecheck`. Full suite (`vitest run --project local`): 320
files, 4555 passed, 19 skipped.
- `@objectstack/cloud-connection`: `typecheck` green, including
`tsconfig.test.json`. Full suite: 36 files, 443 passed.
- `@objectstack/cli`: `typecheck` green. Its test-layer program compiles
the new integration file, counted with `--listFilesOnly` (1 hit).
`--project unit`: 257 files, 3771 passed.
- The install-local integration pins, on built `runtime` and
`cloud-connection` `dist/` (the pull clause present in both door
bundles, the ablation marker absent):
`package-install-local-{jobs-pull,jobs,jobs-shared-name,hooks,handlers,boot-steps,uninstall-cleanups}`,
7 files, 76 passed.
- `pnpm --filter @objectstack/spec exec vitest run --project repo
scripts/liveness/evidence.test.ts`: 42 passed. Every touched symbol was
grepped across `packages/spec/liveness/**` and `*.ledger.*`: only the
two unchanged anchors hit.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths; 9 paths vs merge base
`eed2dee48`): 93 commands. 92 exit 0, and 1 exits 1 by design:
`check-empty-changeset --base origin/main`, the pending release note
corrected above. `--ran` reports 93 derived, 93 run, 0 NOT-MEASURED, 0
UNRUN.
- `check:skill-examples` and `check:dual-build-cjs-loads` first exited 3
(`PREREQUISITE NOT MET`), because packages outside this diff's closure
were unbuilt. Both exited 0 on rerun once those packages were built. The
record carries the reruns.
- Full `pnpm lint` (`eslint . --no-inline-config`): exit 0, no findings.

## Acceptance notes

- `content/docs/references/system/job.mdx` is generated from
`JobSchema.body`'s describe in `packages/spec`. It says `os package
install` "refuses an enabled job with no `body` (a `pull` job excepted:
it is data too)". That stays literally true, because the exception is
from the no-`body` refusal. It is not edited, since `packages/spec` is
out of this card's surface. The next PR that touches
`packages/spec/src/system/job.zod.ts` could add that an unbindable
`pull` is refused too. Not filed.
- Version skew: a newer `@objectstack/runtime` behind an older
`@objectstack/cloud-connection` would describe an unbindable pull job
with the no-`body` clause. That is the wrong remedy, though still a 422.
The two packages are in one `fixed` release group in
`.changeset/config.json`, and the door already tells an operator to
upgrade them together. Not filed.

---
_Generated by [Claude
Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…nction in `handler` and carries no `body` (objectstack-ai#21658) (objectstack-ai#21686)

Fixes objectstack-ai#21658
Clause-②: no (narrowing)

This carries out triage's ruling on objectstack-ai#21658 (comment 5975986454, unlocked
in 5976053780). The ruling inherits from the maintainer's ruling on
objectstack-ai#21604 (comment 5974477722, letter B) and from the install-local door
precedent (objectstack-ai#21585, PR objectstack-ai#21615). **The metadata save door refuses a
body-less `handler` hook with a named error and the prescription "give
it a `body`".** `HookSchema` is untouched.

## What changes

`saveMetaItem` in `packages/metadata-protocol/src/protocol.ts` now
refuses a `hook` whose `handler` is a non-empty string and that carries
no `body` object. Both `PUT /api/v1/meta/hook/:name` and the
dispatcher's metadata save call this door.

- **Envelope:** `VALIDATION_ERROR` / 400. This is the envelope of the
name check the same door runs on every body (`savedItemNameRefusal`). No
new code is added, and the ledger is not edited.
- **Message:** it names the hook and the function and gives the
prescription before the explanation. It stays under the 500-character
REST message bound when each name is shorter than about 65 characters.
The measured text reads: "Invalid hook: 'scope_authored_cross' names the
function 'x_stamp' in its `handler` and carries no `body`, so it can
never run. Give it a `body` (sandboxed JS, `{ language: 'js', source }`,
or an expression), which is stored with the hook. A hook saved through
the metadata API ships with no code package, so it holds no functions,
and a `handler` name resolves only inside the hook's own package."
- **When:** in draft mode and in publish mode, before anything is stored
or bound.
- **Where in the door:** right after the type schema accepts the body,
and before the runtime authoring gate and every write. H1 below explains
the placement.

The diff adds one module-level helper with its TSDoc,
`runtimeHookWithoutBodyRefusal`, and one call site.

## Why such a hook can never bind (measured)

- `ObjectQLPlugin`'s authored-hook re-sync binds every stored hook under
the synthetic owner `metadata-service`, with no `functions` map. Both
bind sites in `packages/objectql/src/plugin.ts` do this.
- Since PR objectstack-ai#21653, the binder looks a name up in two places only: the
bind's own `functions`, and an engine function whose owner is the bind's
package. Nothing registers a function under `metadata-service`.
- So the name has nothing to bind to. Before this change, the door
answered 200 with `Saved hook 'scope_authored_cross' (env-wide,
state=active)`. The binder then refused the stored hook three times
(`INVALID_REFERENCE` / 400, logged at `error`). The ablation run below
reproduces exactly this.

## The PM's mechanism hypotheses, measured

| # | Hypothesis | Reading |
|:--|:--|:--|
| H1 | The check sits beside the view checks. | **Falsified in part, by
choice.** The check sits one step later, right after the type-schema
parse. Beside the view checks, a hook with a malformed `body` (a string,
say) would be told "give it a `body`", which misdescribes a hook that
has one. After the parse, `body` is either absent or a declared hook
body, so the binder's body-first test is exact. The check still runs
before the authoring gate and before every write. A pin covers this: a
malformed `body` beside a `handler` gets the schema's `422
INVALID_METADATA` located at `body`. |
| H2 | The predicate. A hook with both a `body` and a `handler` stays
allowed. | **Holds.** `HookSchema` declares both keys optional and does
not make them exclusive, and the binder runs `body` first. Pinned at the
unit level and at the composed door: the hook with both binds and runs
its body, and `x_stamp` never runs. |
| H3 | `VALIDATION_ERROR` / 400. | **Holds.** Install-local answers
`VALIDATION_ERROR` / 422 on its own door. This door's name refusal and
its view-container refusals answer `VALIDATION_ERROR` / 400, so 400
keeps one dialect per door. No new code is needed. |
| H4 | The re-savers record a failure, and stored rows keep their bytes.
| **Holds.** Measured with a one-off harness that is not committed.
`duplicatePackage` on a package holding a handler-only hook row and a
body hook row answered `{ success: false, copiedCount: 1, failedCount: 1
}`. This refusal was in `failed[0].error`, and the source row's bytes
were unchanged. `migrateStoredMetadata({ apply: true })` on such a row
answered `{ scanned: 1, canonical: 1, rewritten: 0, failed: 0 }`, with
the bytes unchanged. No conversion is pending for such a row, so it is
never re-saved. |
| H5 | No artifact or install path calls `saveMetaItem` for a hook. |
**Holds.** Every call site at `e9162b1180` falls in one of two groups.
The callers that forward an author's or a stored row's type are the REST
`PUT /meta/:type/:name` and its compound twin, the dispatcher's metadata
save, `migrateStoredMetadata` and `duplicatePackage`. The fixed-type
callers are `automation.ts` and `flow-credential-migration.ts` (flow),
`packages.ts` (app) and `permission-set-projection.ts` (permission).
`AppPlugin`, `loadArtifactBundle`, the install-local door and the boot
path make zero `saveMetaItem` calls. |

## Scope: only the `handler` form

A hook with neither a `body` nor a `handler` never runs either. Measured
at the composed door: `PUT` answered 200, and the binder warned
`skipping hook with unresolved handler`. This PR still refuses only the
`handler` form, for two reasons:

- The ruling and the claim name only the `handler` form.
- The bare shape is the schema-valid probe body in at least five
existing suites: `protocol.code-only-types`,
`protocol.meta-types-mint-door-agreement` and
`protocol.unrecognised-meta-type` in metadata-protocol, and
`overlay-precedence` and `protocol-meta` in objectql.

Widening the predicate is a separate call. It goes to the seat as a
finding and is not folded in here.

## Pins (ADR-0112: each refusal asserts `code` and `status`)

| Pin (triage 5975986454) | Where |
|:--|:--|
| 1. The measured `PUT` is refused with the named error, and nothing is
stored or bound. | **Composed kernel**,
`packages/runtime/src/hook-handler-package-scope.pin.test.ts`. Case ②
asserts 400, the body `{ error, code: 'VALIDATION_ERROR' }`, the names
of the hook and the function, the `body` prescription, and a 404 on the
by-name GET. Case "② nothing bound" asserts that the binder recorded no
refusal of the hook after the re-sync ran. **Unit**, section 7 of
`protocol.invalid-metadata-422-face-inventory.test.ts`: publish and
draft mode each assert `code`, `status` and an empty store. |
| 2. A body hook saves and binds. | **Composed** case ②b: a body hook
and a body-plus-handler hook both bind and run, and `x_stamp` never
runs. **Unit**: the CONTROL case and the body-beside-handler case. |
| 3. A built artifact's `handler` hook is unchanged on its own door. |
**Composed** controls. App X's hook names its own `functions` entry and
binds and runs. App Z's hook names a function that its `--artifact`
runtime module exports (loaded with `loadArtifactBundle`), and it binds
and runs. |

Before this PR, the composed case ② recorded the door's 200 and asserted
the refusal at bind. It now asserts the refusal at the door. The
binder's refusal for the `metadata-service` owner is still pinned in
objectql's `hook-binder-package-scope.test.ts`, which is green below.

## Reverse verification (the fix committed first, at `7d9d4b4221`)

**Mutation.** `node scripts/ablation-replace.mjs` replaced `if
(hookRefusal) throw hookRefusal;` with a marker log. Anchor count 1 → 0;
blob `3496aca9fec3` → `03aa7af3511c`. `@objectstack/metadata-protocol`
was then rebuilt, and `node scripts/ablation-dist-preflight.mjs
@objectstack/metadata-protocol ABLATED_21658_HOOK_REFUSAL` found the
marker in `dist/index.js` and `dist/index.cjs`.

**Prediction:** pin 1 red, pins 2 and 3 green. **Observed:**

- **Unit (src):** publish ✗ and draft ✗. CONTROL ✓, body beside handler
✓, malformed body ✓. 2 failed, 21 passed.
- **Composed (dist):**
- ② ✗: `expected { status: 200, … }`, with the body `Saved hook
'scope_authored_cross' … state=active`.
  - "② nothing bound" ✗: the binder recorded 3 refusals.
  - ②b ✓, ① ✓, X control ✓, Z control ✓.
  - 2 failed, 4 passed.

**Restore.**

- `ablation-replace` restored the path: blob == HEAD (`3496aca9fec3`)
and `git diff HEAD` is empty. A shell trap also ran `git checkout HEAD
-- …`.
- Whole-tree `git status --porcelain` is empty.
- After a rebuild, the `--absent` preflight found the marker in none of
the 24 built files, and the tree was clean.
- The reruns are green: 23/23 and 6/6.

## Tests (at `e9162b1180`, after merging `origin/main` `7d0781482d`)

- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2`: 209 files passed and 3 skipped; 3468 tests passed and
19 skipped.
- Typecheck, exit 0 for both packages:
- metadata-protocol `typecheck`. Its tsc program includes the edited
test file (`--listFiles` count: 1).
- runtime `typecheck`: tsc plus `check:test-typecheck`, OK, debt ledger
held.
- Runtime `hook-handler-package-scope.pin.test.ts` and
`stored-metadata-body-boundary.pin.test.ts`: 13/13.
- objectql `protocol-meta`, `overlay-precedence`,
`plugin-authored-hooks` and `hook-binder-package-scope`: 139/139.
- Dependency closure: `pnpm turbo run build
--filter='@objectstack/runtime^...' --concurrency=2`, 29/29.
- The `packages/runtime` tests outside these files are declared to CI.

## Gates (at `e9162b1180`)

**Derived.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths)
derives 64 families, and all 64 ran.

- 63 exited 0.
- `check:dual-build-cjs-loads` exited 3: PREREQUISITE NOT MET. It needs
a full `pnpm build`, and more than 30 packages outside this closure have
no `dist/`. NOT MEASURED. Targeted reading instead:
`require('./packages/metadata-protocol/dist/index.cjs')` loads with 83
exports.
- The `--ran` reconciliation: 64 accounted for, 63 run, 1 NOT-MEASURED,
0 UNRUN.

**Artifact-roster block** (54 families, outside the derived total). All
54 ran.

- 51 exited 0. These include `check:error-status-conformance`,
`check:error-code-casing`, `check:authz-resolver`,
`check:route-ledger-census`, `check-changeset-fixed` and
`check:engine-double-contract`.
- 3 exited 2 and are NOT WIRED without PR context:
`check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths`. They are rerun with this PR's context, and
the results go in the os-dev report.

**Lint.** CI owns `pnpm lint`. This PR records a proven narrowing
instead:

- **Population:** `eslint.config.mjs` lints `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`.
- **Count:** `eslint --no-inline-config --format json` over the 3
changed TS files reports 3 files, 0 errors and 0 warnings.
- **Invariance:** the config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict of any
untouched file.

## Changeset

`.changeset/21658-hook-handler-without-body-save-door.md`: `minor` for
`@objectstack/metadata-protocol`, `Clause-②: no (narrowing)`, the
BREAKING banner, and the ADR-0087 marker `not-required
(no-migration-prescription)` with the census.
`check-adr-0087-registration --base origin/main` accepts it.

## Landing point

As the claim predicted: `packages/metadata-protocol/src/protocol.ts`,
`saveMetaItem`, type `hook`. No producer elsewhere needs a change.

## Acceptance notes

- **The draft-promotion and restore doors do not re-ask this rule.**
`publishMetaItem`, `rollbackMetaItem` and `revertCommit` can still make
a draft or a history version stored before this change into an active
handler-only row. The runtime then refuses that row at bind, as before.
The rule covers the save door only, as the same door's view-container
refusal does. Carrier: none.
- **Kernels with no `environmentId`.** A save there under the name of an
artifact-shipped hook writes a row the re-sync skips
(`isArtifactShippedHook`). So a GET-then-PUT round trip of an artifact
hook's served `handler` body is now refused on such a kernel. Before, it
stored an inert row that was never bound. Environment-scoped kernels
already refuse that write (`refusePackagedBaseOverride`). Carrier: none.
- **One finding goes to the seat in the os-dev report:** a hook with
neither a `body` nor a `handler` (see Scope).

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

2 participants