Skip to content

Commit 7fd2c34

Browse files
fix(metadata-protocol)!: the save door refuses every hook with no body, including one with neither a body nor a handler (#21689) (#21706)
Fixes #21689 Clause-②: no (narrowing) This carries out triage's ruling on #21689 (comment 5977077883, unlocked in 5977495602): **one predicate. The metadata save door refuses any hook with no `body`**, with a named error and the prescription "give it a `body`". The handler-only refusal that #21658 landed (PR #21686, `ced217ca30`) is now one case of it. `HookSchema` is untouched. ## What changes `runtimeHookWithoutBodyRefusal` in `packages/metadata-protocol/src/protocol.ts` keeps its name, its one call site in `saveMetaItem` and its one envelope. Its predicate widens from "a non-empty `handler` string and no `body` object" to "no `body` object". It is the same judgement install-local's `collectHooksWithoutBody` makes on its own door (`hookCarriesBody` in `packages/runtime/src/app-artifact-handlers.ts`). - **Envelope:** `VALIDATION_ERROR` / 400, unchanged. No code is added and the ledger is not edited. - **When and where:** unchanged from #21658. It runs in draft and in publish mode, after the type-schema parse (a malformed `body` keeps the schema's located 422), and before the authoring gate and every write. - **Message, two readings of one rule:** - With a `handler` that names a function, the message is byte-identical to the one #21658 shipped. It names the hook and the function. - With neither field, or an empty `handler`: "Invalid hook: 'NAME' carries no `body`, so it has nothing to run. Give it a `body` (sandboxed JS, `{ language: 'js', source }`, or an expression), which is stored with the hook. A hook saved through the metadata API ships with no code package, so its `body` is the only code it can run." - Length: the handler form is 499 characters with 65-character names, as before. The neither form is 282 characters plus the hook name, well under the 500-character REST bound. - **What still saves:** a hook with a `body`, with or without a `handler` beside it. ## The PM's mechanism hypotheses, measured (at `1db5322ba2`, then rerun on the merged head `15402d98f8`) | # | Hypothesis | Reading | |:--|:--|:--| | H1 | Widen the existing predicate, one function, one call site, one code. | **Holds.** One early return removed, and the message chosen by whether `handler` names a function. The measured messages read right for both shapes (quoted above; the sweep's failure output below shows the neither text verbatim as the door produced it). | | H2 | Five suites use a bare hook as their schema-valid probe; report any others. | **Holds, with two more.** With the predicate widened and every probe untouched, the full `metadata-protocol` suite went 7 failed / 3461 passed (4 files), and the full `objectql` suite went 6 failed / 7446 passed (3 files). The five named suites, plus `metadata-protocol` `protocol.save-receipt-wording` and `objectql` `metadata-validation-sweep`. A grep of `runtime`, `rest`, `plugins/*`, `services/*`, `cli`, `verify`, `qa` and `examples/**` for hook saves through this door (`type: 'hook'` items, `/meta/hook` paths) found only `runtime`'s two pin tests, which already carry bodies. See the fixture triage below. | | H3 | `migrateStoredMetadata` and `duplicatePackage` surface a stored bare row as their recorded failure; stored rows keep their bytes. | **Holds.** Measured with a one-off harness that is not committed (the stub engine of `protocol.stored-residue-resave.test.ts`). `duplicatePackage` over a package holding one bare hook row and one body hook row answered `{ success: false, copiedCount: 1, failedCount: 1 }` with this refusal in `failed[0].error`, and the source rows' bytes were unchanged. `migrateStoredMetadata({ apply: true })` over a bare row answered `{ scanned: 1, canonical: 1, rewritten: 0, failed: 0 }` with the bytes unchanged. **Population of stored bare hooks reachable from this repository: zero.** `examples/**` and `packages/qa/**` seed no `sys_metadata` hook rows (zero `type: 'hook'` items). | | H4 | A built artifact's `handler` hook never passes `saveMetaItem`. | **Holds at `1db5322ba2`.** Every production call site either saves a fixed type other than `hook` (`automation.ts` and `flow-credential-migration.ts` for flow, `packages.ts` for app, `permission-set-projection.ts` for permission) or forwards an author's or a stored row's type. The forwarding callers are the REST `PUT /meta/:type/:name` (`rest-server.ts`), the dispatcher's metadata save (`domains/meta.ts`), `migrateStoredMetadata` and `duplicatePackage`. `AppPlugin`, `loadArtifactBundle`, the install-local door and the boot path make zero `saveMetaItem` calls. The composed pin's X and Z controls hold it end to end. | | H5 | No first-party authoring path emits a hook with neither field through this door. | **Holds for what this repository holds.** `os meta register` forwards the author's own file and emits no hook of its own. The `os` create and scaffold templates author `defineStack` sources, which reach the artifact door and never this one. `packages/mcp` has no hook tool. Studio is at the objectui pin `ab18797215`, unchanged since #21658's census, which read its hook skeleton carrying a `body`. **Not measured:** the cloud AI build agent's metadata tools, which live outside this repository. | ## Fixture triage: seven probe suites move to a body-carrying hook Each probe item gains `body: { language: 'js', source: 'return;' }` and nothing else. No probe is deleted, and no assertion is changed or loosened. | Suite | What the probe measures | Still measures it | |:--|:--|:--| | `metadata-protocol` `protocol.code-only-types` (2 probes) | The #5086 code-only gate does not catch `hook` (`allowRuntimeCreate` only) on either kernel; the #5264 matrix shows a hook save answers a repository receipt. | Yes: `success: true` and one row on both kernels, and the receipt fields. | | `metadata-protocol` `protocol.meta-types-mint-door-agreement` | `PUT /meta/hook` behaves as `/meta/types` advertises (declared, creatable), read off one fact. | Yes: both cases are green. | | `metadata-protocol` `protocol.unrecognised-meta-type` | A declared runtime-create-only type still saves past the unrecognised-type refusal. | Yes. | | `objectql` `overlay-precedence` (3 probes: `hook`, plural `hooks`, single-kernel bypass) | The two-tier verdict: brand-new items of `allowRuntimeCreate` types pass the overlay whitelist, and a single kernel bypasses the overlay gate. | Yes. | | `objectql` `protocol-meta` (3 probes) | The PR-10d.7 two-tier model: an artifact-backed hook is refused `NOT_OVERRIDABLE` / 403; a brand-new hook and an edit of a DB-only hook are accepted. | Yes. The `NOT_OVERRIDABLE` probe was green before the move too, because the provenance gate runs first. It moved anyway, so that the refusal it measures can only be the provenance gate's. The registry-seeded items there are scenery for provenance, not saves through the door, and keep their bytes. | | **Beyond the five:** `metadata-protocol` `protocol.save-receipt-wording` (`OVERLAYLESS_PROBES.hook`) | The receipt sentence for a brand-new overlay-less type. | Yes. | | **Beyond the five:** `objectql` `metadata-validation-sweep` (`FIXTURES.hook`) | Valid gets 200; invalid gets the schema's 422 naming `events`. | Yes. The invalid fixture carries the body too, so it stays the valid fixture minus the one field the schema must name. | ## The enumeration pin, on the real door (ADR-0112: each refusal asserts `code` and `status`) Composed kernel, `packages/runtime/src/hook-handler-package-scope.pin.test.ts`, through `PUT /api/v1/meta/hook/NAME` as the signed-in administrator: | Pin | Case | Asserts | |:--|:--|:--| | 1. A `body` hook saves. | ②b (existing) | 2xx; it binds and runs, and so does one with both fields. | | 2. A handler-only hook is refused. | ② (existing) | 400 `VALIDATION_ERROR`, naming the hook and the function and prescribing a `body`; GET by name answers 404. | | 3. A hook with neither field is refused. | **②c (new)** | 400 `VALIDATION_ERROR`, naming the hook and prescribing a `body`; GET by name answers 404. | | (2 and 3) Nothing bound. | **"② and ②c nothing bound" (widened)** | After ②b's re-sync, the binder recorded no refusal of either hook and no skip of the bare one (`skipsOf`, a new reader of the engine logger's `skipping hook` warns). | | 4. A built artifact's `handler` hook through its own door is unchanged. | X and Z controls (existing) | App X's hook naming its own `functions` entry binds and runs. App Z's hook naming a function its `--artifact` runtime module exports binds and runs. | At unit level, section 8 of `protocol.invalid-metadata-422-face-inventory.test.ts` is new. It covers publish and draft with neither field, and an empty `handler`. Each case asserts `code`, `status`, the named hook, the prescription and an empty store. Section 7 (#21658) is unchanged and still green. ## Reverse verification (the fix committed first, at `0c32c32bb1`) **Mutation.** `node scripts/ablation-replace.mjs` restored the old handler-only guard after the body test: `typeof hook.handler !== 'string' || hook.handler === ''` returns `undefined`, behind a marker constant `ABLATED_21689_NEITHER`. The anchor count went 1 to 0 and the blob went `3059168d5703` to `237d2559c48b`. `@objectstack/metadata-protocol` was rebuilt, and `node scripts/ablation-dist-preflight.mjs @objectstack/metadata-protocol ABLATED_21689_NEITHER` found the marker in `dist/index.js` and `dist/index.cjs`. A shell `trap` restore on EXIT, INT and TERM wrapped the whole run. The first attempt was a **no-op**, and it is disclosed here. Its replacement re-contained the anchor line, so `ablation-replace` refused it ("the anchor count moved 1 to 1"), ran nothing and proved the restore. The second attempt re-spelled the body test (`typeof hook.body === 'object' && hook.body`, the same truth table), so the anchor left the file. **Prediction:** pin 3 red, and pins 1, 2 and 4 green. **Observed:** - **Unit (src), sections 4 to 8:** 3 failed, all three section 8 cases (publish neither, draft neither, empty `handler`). 15 passed, including all of section 7 (handler-only refused in both modes, the body CONTROL, body beside handler, malformed body 422). - **Composed (dist):** - ②c failed. It received `{ status: 200 }` with `Saved hook 'scope_authored_bare' (env-wide, state=active)`. - "② and ②c nothing bound" failed. `skipsOf('scope_authored_bare')` held 3 binder skips, which also proves the new reader fires. - ① ✓, X control ✓, Z control ✓, ② ✓ and ②b ✓: 2 failed, 5 passed. **Restore.** - `ablation-replace` restored the file. The blob equals HEAD (`3059168d5703`) and `git diff HEAD` is empty. The outer trap's hash compare agreed. - Whole-tree `git status --porcelain` is empty. - After a rebuild, the `--absent` preflight found the marker in none of the 24 built files, and the tree was clean. - The reruns are green: 26/26 (face inventory) and 7/7 (composed). ## Tests (at `15402d98f8`, after merging `origin/main` `8843505d91`, which carries PR #21693) - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 210 files passed and 3 skipped; 3578 tests passed and 19 skipped. - `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 --project local`: 372 files and 7455 tests passed. - Runtime `hook-handler-package-scope.pin.test.ts` and `stored-metadata-body-boundary.pin.test.ts`: 14/14. - Typecheck, exit 0 for all three packages: - metadata-protocol `tsc --noEmit`. Its program includes all five edited test files (`--listFiles`, one hit each). - objectql and runtime: `tsc` plus `check:test-typecheck`, OK, with the debt ledgers held. Their test layers compile under `tsconfig.test.json` (`include: src/**/*`). - Dependency closure: `pnpm turbo run build --filter='@objectstack/runtime^...' --concurrency=2`, 29/29. `packages/spec` moved on main's side, so `pnpm --filter @objectstack/spec check:generated` also ran: all 15 generated artifacts are up to date. - The rest of `packages/runtime`'s suite is declared to CI. ## Gates (at `15402d98f8`) Every family below ran first at `623b4a0b94` and ran again in full on the merged head `15402d98f8`. The figures are the second run's. **Derived.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derives 66 families, the same list on both heads, and all 66 ran. - All 66 exited 0. That includes `check:dual-build-cjs-loads`: 106 published require entry points across 66 packages load. On the first head it had exited 3, PREREQUISITE NOT MET, for want of a full build. - `--ran` reconciliation: 66 accounted for, 66 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. **Artifact-roster block** (54 families, outside the derived total). All 54 ran. - 51 exited 0. These include `check:error-status-conformance`, `check:error-code-casing`, `check:authz-resolver`, `check:route-ledger-census`, `check-changeset-fixed` and `check:engine-double-contract`. - 3 exited 2, NOT WIRED without PR context: `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`. They are rerun with this PR's context, and the results go in the os-dev report. **Symbol-anchor sweeps**, all exit 0: - `check:adr-symbol-anchors`: 2167 anchors across 140 records. - `check:scripts-symbol-anchors`: 3760 anchors across 282 scripts. - `check:spec-docblock-symbol-anchors`: 4867 anchors across 1861 spec sources. - `check:adr-anchors`: OK. **Lint.** CI owns `pnpm lint`. This PR records a proven narrowing instead: - **Population:** `eslint.config.mjs` lints `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`. - **Count:** `eslint --no-inline-config --format json` over the 10 changed TS files reports 10 files, 0 errors and 0 warnings. - **Invariance:** the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict of any untouched file. ## Changeset `.changeset/21689-hook-no-body-save-door.md`: `minor` for `@objectstack/metadata-protocol`, `Clause-②: no (narrowing)`, the BREAKING banner, and the ADR-0087 marker `not-required (no-migration-prescription)` with the census above. `check-adr-0087-registration --base origin/main` accepts it. ## Landing point As the claim predicted: `packages/metadata-protocol/src/protocol.ts`, `runtimeHookWithoutBodyRefusal` (`saveMetaItem`, type `hook`), plus the seven probe suites. No producer elsewhere needs a change. No governed surface is touched. PR #21693, which edits the read region and the import block of `protocol.ts`, landed on main before this PR opened. It is merged in here; the merge was clean, and this diff touches neither region. ## Acceptance notes - **The draft-promotion and restore doors still do not re-ask this rule.** #21658's PR recorded this for the `handler` form, and it now covers the neither form too. `publishMetaItem`, `rollbackMetaItem` and `revertCommit` can make a draft or a history version stored before this change into an active bare row, which the runtime skips at re-sync as before. Carrier: none. - **`os meta register hook --data FILE`** forwards the author's file through this door, so a bare hook file now gets this 400 and the CLI prints its message. It needs no change of its own. - **The binder's skip line** reads `skipping hook with unresolved handler` for a hook that has no `handler`. No stored row of that shape can be minted through this door any more. Noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e0066a commit 7fd2c34

11 files changed

Lines changed: 202 additions & 54 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
The runtime save door refuses every hook that carries no `body`, including one with neither a `body` nor a `handler`: a hook stored there ships with no code package, so its `body` is the only code it can run
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at one runtime write door over existing keys: no key of `HookSchema` or of any other metadata schema is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. What `body` a refused hook should carry is authoring intent no conversion entry can decide. New saves are refused with the remedy; a row stored before this change keeps its bytes, and no stored row is re-saved (measured: `migrateStoredMetadata({ apply: true })` on a stored hook row with neither field reports it canonical, failed 0, and the bytes are unchanged). The census of writers through this door, taken first at `1db5322ba2`: Studio at the objectui pin (`ab18797215`, unchanged since the census of the handler-only refusal, which read its hook skeleton carrying a `body`); objectstack `examples/**` and `packages/qa/**` seed no `sys_metadata` hook rows (zero `type: 'hook'` items); `os meta register` forwards the author's own file and emits no hook of its own; the artifact, boot and install-local doors never call `saveMetaItem` for a hook (every call site in the tree saves a fixed type other than `hook`, or forwards an author's request: the REST and dispatcher `/meta` saves, `migrateStoredMetadata`, `duplicatePackage`). The only first-party bodies of this shape were test probes (seven suites), which now carry a `body`. Package duplication of a package holding such a row now reports that row as failed with this refusal (measured: copied 1, failed 1, the source rows' bytes unchanged). Hosted tenants and the cloud AI author were not measured. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered); and the change narrows what a runtime write door accepts, not a runtime interface or a type surface alone (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** accept-set narrowing at the runtime save door, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the same door's earlier refusals shipped with.
12+
13+
**One rule.** `saveMetaItem`, which `PUT /api/v1/meta/hook/:name` and the dispatcher's metadata save both call, now refuses every `hook` that carries no `body`. It already refused a hook whose `handler` names a function and that carries no `body`; that refusal is now one case of this rule, with the same envelope and the same message. The new case is a hook with neither field (or with an empty `handler`). Before this change the door answered 200 for it, the hook was served by name, the runtime skipped it at every re-sync (`skipping hook with unresolved handler`, in the server log only), and it never ran. The refusal is `VALIDATION_ERROR` / 400, in draft and in publish mode, before anything is stored or bound. It names the hook and prescribes a `body`.
14+
15+
**Before and after** (with `{ name: 'stamp_status', object: 'crm_note', events: ['beforeInsert'] }`):
16+
17+
- Before: 200 `Saved hook 'stamp_status'`, the row stored and served by name, and the hook never run.
18+
- After: 400 `VALIDATION_ERROR`, naming `stamp_status`, and nothing stored.
19+
20+
**What still saves.** A hook with a `body`, with or without a `handler` beside it: the binder runs the body and never consults the name. A malformed `body` still gets the type schema's located `422 INVALID_METADATA`.
21+
22+
**What is unchanged.** `HookSchema` still accepts a hook with no `body`, because a build artifact carries a `handler` hook: `objectstack build` lowers an inline function to the hook's name and ships the function in the artifact's runtime module. A hook in an artifact or a `defineStack` config binds on its own door, which never reaches this one. `os validate` and `os build` are unchanged.
23+
24+
**Rows stored before this change.** They keep their bytes, nothing re-saves them, and the runtime skips them at every re-sync as before. A new save of one, a re-save included, is refused until it carries a `body`. Package duplication reports such a row as failed with this refusal; `migrate meta --stored` leaves it as it is. Delete stays open.
25+
26+
**The fix.** Give the hook a `body`: sandboxed JS (`{ language: 'js', source }`) or an expression (`{ language: 'expression', source }`). A hook that must run a package's own function belongs in that package's code, where its `handler` resolves.

‎packages/metadata-protocol/src/protocol.code-only-types.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,10 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => {
402402
const result = await protocol.saveMetaItem({
403403
type: 'hook',
404404
name: 'rc3_probe_hook',
405-
item: { name: 'rc3_probe_hook', object: 'task', events: ['beforeUpdate'] },
405+
// [#21689] A body-carrying hook: the save door refuses a hook
406+
// with no `body` (it could never run), and this case measures
407+
// the code-only gate, not that refusal.
408+
item: { name: 'rc3_probe_hook', object: 'task', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } },
406409
});
407410
expect(result.success).toBe(true);
408411
expect(metaRows(rows).length).toBe(1);
@@ -511,7 +514,9 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => {
511514
},
512515
{
513516
type: 'hook', // allowRuntimeCreate only
514-
item: { name: 'rc3_receipt_view', object: 'task', events: ['beforeUpdate'] },
517+
// [#21689] With a `body`: the door refuses a hook without one,
518+
// and this matrix measures the receipt, not that refusal.
519+
item: { name: 'rc3_receipt_view', object: 'task', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } },
515520
},
516521
{
517522
type: 'webhook', // no static registry entry (plugin-registered)

‎packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,3 +570,50 @@ describe('[#21658] a hook naming a function in `handler` with no `body` is refus
570570
expect(rows.size).toBe(0);
571571
});
572572
});
573+
574+
// ═══════════════════════════════════════════════════════════════════════════
575+
// 8. #21689 — one predicate: the `hook` door refuses every hook with no `body`
576+
// ═══════════════════════════════════════════════════════════════════════════
577+
//
578+
// Section 7's refusal, generalised. A hook this door stores ships with no code
579+
// package, so a `body` is the only code it can run: a hook with neither a
580+
// `body` nor a `handler` is never bound either (the binder skips it at every
581+
// re-sync). The door judges by one predicate — no `body` object — and section
582+
// 7's `handler` form is one case of it, with the same envelope. An empty
583+
// `handler` names no function, so it reads as no `handler`.
584+
585+
describe('[#21689] a hook with no `body` and no function in `handler` is refused at the metadata door', () => {
586+
const bare = (extra: Record<string, unknown> = {}) => ({
587+
name: 'stamp_status',
588+
object: 'hks_note',
589+
events: ['beforeInsert'],
590+
...extra,
591+
});
592+
593+
it.each([
594+
['publish', 'neither field', undefined, {}],
595+
['draft', 'neither field', 'draft', {}],
596+
['publish', 'an empty `handler`', undefined, { handler: '' }],
597+
] as const)('%s mode, %s — VALIDATION_ERROR / 400, naming the hook, prescribing a `body`, nothing stored', async (_label, _shape, mode, extra) => {
598+
const { protocol, rows } = makeProtocol();
599+
let err: any;
600+
try {
601+
await protocol.saveMetaItem({
602+
type: 'hook',
603+
name: 'stamp_status',
604+
item: bare(extra),
605+
writeFace: 'meta-envelope',
606+
actor: 'usr_admin',
607+
...(mode ? { mode } : {}),
608+
});
609+
} catch (e) {
610+
err = e;
611+
}
612+
613+
expect(err).toBeInstanceOf(Error);
614+
expect({ code: err.code, status: err.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 });
615+
expect(err.message).toContain("'stamp_status'");
616+
expect(err.message).toContain('Give it a `body`');
617+
expect(rows.size).toBe(0);
618+
});
619+
});

‎packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,10 @@ const SAMPLE: Array<{
143143
type: 'hook',
144144
klass: 'declared',
145145
creatable: true,
146-
item: { name: 'probe_hook', object: 'task', events: ['beforeInsert'] },
146+
// [#21689] With a `body`: the mint door refuses a hook without one (it
147+
// could never run), which would misread the advertisement this suite
148+
// measures, as a 422 from schema resolution would.
149+
item: { name: 'probe_hook', object: 'task', events: ['beforeInsert'], body: { language: 'js', source: 'return;' } },
147150
},
148151
{
149152
// The `false` direction of class 1, and it must be present: a listing

‎packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,9 @@ const OVERLAYLESS_PROBES: Record<string, Record<string, unknown>> = {
129129
sharingModel: 'private',
130130
fields: { name: { type: 'text', label: 'Name' } },
131131
},
132-
hook: { name: 'rc5_acct', object: 'task', events: ['beforeUpdate'] },
132+
// [#21689] With a `body`: the door refuses a hook without one before the
133+
// receipt is built, as it refuses a body the schema rejects.
134+
hook: { name: 'rc5_acct', object: 'task', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } },
133135
seed: { object: 'task', records: [] },
134136
action: { name: 'rc5_acct', label: 'Convert', type: 'script', objectName: 'task', target: 'convertHandler' },
135137
flow: {

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 55 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -781,47 +781,58 @@ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null
781781
}
782782

783783
/**
784-
* [#21658] The save door's refusal of a `hook` whose `handler` names a
785-
* function and that carries no `body`: such a hook can never run once this
786-
* door has stored it.
787-
*
788-
* Why it can never bind. A hook's `handler` name resolves inside the hook's
789-
* own package only (the maintainer's ruling on #21604, letter B; the binder's
790-
* `resolveHandler` in `@objectstack/objectql`'s `hook-binder.ts`). A hook this
791-
* door stores ships with no code package: the runtime binds every stored hook
792-
* under the synthetic owner `metadata-service` (`ObjectQLPlugin`'s authored
793-
* hook re-sync), with no `functions` map, and no package of that name
794-
* registers functions. So the name has nothing to resolve against, and the
795-
* binder refuses the hook at registration (`INVALID_REFERENCE` / 400, logged
796-
* at `error`) after this door has already answered success. Refusing it here
797-
* says so to the author, before anything is stored.
798-
*
799-
* The predicate is the binder's own body-first test: a `body` object is bound
800-
* through the body runner and the `handler` is never consulted, so a hook
801-
* carrying BOTH a `body` and a `handler` saves (its body runs), as it installs
802-
* on the install-local door. Asked after the type schema has accepted the
803-
* body, so `body` here is either absent or a declared hook body, and a
804-
* malformed `body` gets the schema's own located `422` instead of this
805-
* refusal's "give it a body".
784+
* [#21658, #21689] The save door's refusal of a `hook` that carries no `body`:
785+
* such a hook can never run once this door has stored it. One predicate, two
786+
* shapes it meets, one envelope: a hook whose `handler` names a function, and a
787+
* hook with neither field.
788+
*
789+
* Why it can never run. A hook this door stores ships with no code package:
790+
* the runtime binds every stored hook under the synthetic owner
791+
* `metadata-service` (`ObjectQLPlugin`'s authored hook re-sync), with no
792+
* `functions` map, and no package of that name registers functions. So a
793+
* `body`, which is stored with the hook, is the only code it can run.
794+
*
795+
* - A `handler` name resolves inside the hook's own package only (the
796+
* maintainer's ruling on #21604, letter B; the binder's `resolveHandler` in
797+
* `@objectstack/objectql`'s `hook-binder.ts`), so it has nothing to resolve
798+
* against, and the binder refuses the hook at registration
799+
* (`INVALID_REFERENCE` / 400, logged at `error`).
800+
* - A hook with neither field has nothing to bind at all, and the binder
801+
* skips it at every re-sync (`skipping hook with unresolved handler`,
802+
* logged at `warn`).
803+
*
804+
* Either way the door would already have answered success, and the hook would
805+
* be served by name and never run. Refusing it here says so to the author,
806+
* before anything is stored.
807+
*
808+
* The predicate is the binder's own body-first test, the judgement
809+
* install-local's `collectHooksWithoutBody` makes on its own door (#21585): a
810+
* `body` object is bound through the body runner and the `handler` is never
811+
* consulted, so a hook carrying BOTH a `body` and a `handler` saves (its body
812+
* runs), and every hook without a `body` object is refused, whatever its
813+
* `handler` holds. Asked after the type schema has accepted the body, so
814+
* `body` here is either absent or a declared hook body, and a malformed `body`
815+
* gets the schema's own located `422` instead of this refusal's "give it a
816+
* body".
806817
*
807818
* ⛔ Not a `HookSchema` rule: a build artifact legitimately carries the string
808819
* form (`objectstack build` lowers an inline function to the hook's name and
809820
* ships the function in the artifact's runtime module), and the artifact and
810821
* boot doors never reach `saveMetaItem`. This is the runtime-authoring door's
811-
* rule only, the same shape install-local refuses on its own door (#21585).
822+
* rule only.
812823
*
813824
* Every writer through this door is judged: the REST and dispatcher saves, in
814825
* draft and in publish mode, and the two server-stated re-savers
815826
* (`migrateStoredMetadata`, `duplicatePackage`), which record this refusal as
816827
* the row's failure. A row stored before this rule keeps its bytes.
817828
*
818829
* `VALIDATION_ERROR` / 400, the envelope of the name check the door runs on
819-
* every body (`savedItemNameRefusal`). The message names the hook and its
820-
* `handler`, prescribes the `body` first, and only then explains: a 4xx
821-
* message crosses the REST boundary bounded at 500 characters with its TAIL
822-
* truncated, and the whole sentence stays under that bound for any hook and
823-
* function name shorter than about 65 characters each. Runtime words carry no
824-
* tracker number.
830+
* every body (`savedItemNameRefusal`). The message names the hook (and the
831+
* function, when its `handler` names one), prescribes the `body` first, and
832+
* only then explains: a 4xx message crosses the REST boundary bounded at 500
833+
* characters with its TAIL truncated, and the whole sentence stays under that
834+
* bound for any hook and function name shorter than about 65 characters each.
835+
* Runtime words carry no tracker number.
825836
*/
826837
function runtimeHookWithoutBodyRefusal(
827838
singularType: string,
@@ -832,12 +843,15 @@ function runtimeHookWithoutBodyRefusal(
832843
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
833844
const hook = item as { handler?: unknown; body?: unknown };
834845
if (hook.body && typeof hook.body === 'object') return undefined;
835-
if (typeof hook.handler !== 'string' || hook.handler === '') return undefined;
846+
const prescription = 'Give it a `body` (sandboxed JS, `{ language: \'js\', source }`, or an expression), '
847+
+ 'which is stored with the hook. A hook saved through the metadata API ships with no code package, so ';
836848
const err = new Error(
837-
`Invalid hook: '${saveName}' names the function '${hook.handler}' in its \`handler\` and carries no \`body\`, `
838-
+ 'so it can never run. Give it a `body` (sandboxed JS, `{ language: \'js\', source }`, or an expression), '
839-
+ 'which is stored with the hook. A hook saved through the metadata API ships with no code package, so it '
840-
+ "holds no functions, and a `handler` name resolves only inside the hook's own package.",
849+
typeof hook.handler === 'string' && hook.handler !== ''
850+
? `Invalid hook: '${saveName}' names the function '${hook.handler}' in its \`handler\` and carries no \`body\`, `
851+
+ `so it can never run. ${prescription}it holds no functions, and a \`handler\` name resolves only inside `
852+
+ "the hook's own package."
853+
: `Invalid hook: '${saveName}' carries no \`body\`, so it has nothing to run. ${prescription}`
854+
+ 'its `body` is the only code it can run.',
841855
) as Error & { code: 'VALIDATION_ERROR'; status: 400 };
842856
err.code = 'VALIDATION_ERROR';
843857
err.status = 400;
@@ -18947,12 +18961,13 @@ export class ObjectStackProtocolImplementation implements
1894718961
}
1894818962
}
1894918963

18950-
// [#21658] A hook whose `handler` names a function and that carries
18951-
// no `body` can never run once stored here: a stored hook ships with
18952-
// no code package, and a `handler` name resolves only inside the
18953-
// hook's own package. Refused in draft and in publish mode, after the
18954-
// schema (so `body` is absent or a declared body) and before the
18955-
// authoring gate and every write. See {@link runtimeHookWithoutBodyRefusal}.
18964+
// [#21658, #21689] A hook that carries no `body` can never run once
18965+
// stored here, whether its `handler` names a function or it has
18966+
// neither field: a stored hook ships with no code package, so its
18967+
// `body` is the only code it can run. Refused in draft and in publish
18968+
// mode, after the schema (so `body` is absent or a declared body) and
18969+
// before the authoring gate and every write. See
18970+
// {@link runtimeHookWithoutBodyRefusal}.
1895618971
{
1895718972
const hookRefusal = runtimeHookWithoutBodyRefusal(singularType, request.item, request.name);
1895818973
if (hookRefusal) throw hookRefusal;

‎packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,8 @@ describe('#8421 — the traffic that must keep working', () => {
215215
{
216216
type: 'hook',
217217
why: 'declared, runtime-create only',
218-
item: { name: 'probe_item', object: 'task', events: ['beforeUpdate'] },
218+
// [#21689] With a `body`: the door refuses a hook without one.
219+
item: { name: 'probe_item', object: 'task', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } },
219220
},
220221
{
221222
// `theme` held this slot until commit 35ad101bc retired the themes surface

‎packages/objectql/src/metadata-validation-sweep.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,17 @@ const FIXTURES: Record<string, Fixture> = {
143143
invalidatedField: 'type',
144144
},
145145
hook: {
146+
// [#21689] Both carry a `body`: the save door refuses a hook with no
147+
// `body` (it could never run), so a body-less `valid` would measure
148+
// that refusal, and `invalid` stays `valid` minus the one field the
149+
// schema must name.
146150
valid: {
147151
name: 'sweep_hook',
148152
object: 'sweep_account',
149153
events: ['beforeInsert'],
154+
body: { language: 'js', source: 'return;' },
150155
},
151-
invalid: { name: 'sweep_hook', object: 'sweep_account' },
156+
invalid: { name: 'sweep_hook', object: 'sweep_account', body: { language: 'js', source: 'return;' } },
152157
invalidatedField: 'events',
153158
},
154159
validation: {

0 commit comments

Comments
 (0)