Skip to content

Commit ced217c

Browse files
fix(metadata-protocol)!: the save door refuses a hook that names a function in handler and carries no body (#21658) (#21686)
Fixes #21658 Clause-②: no (narrowing) This carries out triage's ruling on #21658 (comment 5975986454, unlocked in 5976053780). The ruling inherits from the maintainer's ruling on #21604 (comment 5974477722, letter B) and from the install-local door precedent (#21585, PR #21615). **The metadata save door refuses a body-less `handler` hook with a named error and the prescription "give it a `body`".** `HookSchema` is untouched. ## What changes `saveMetaItem` in `packages/metadata-protocol/src/protocol.ts` now refuses a `hook` whose `handler` is a non-empty string and that carries no `body` object. Both `PUT /api/v1/meta/hook/:name` and the dispatcher's metadata save call this door. - **Envelope:** `VALIDATION_ERROR` / 400. This is the envelope of the name check the same door runs on every body (`savedItemNameRefusal`). No new code is added, and the ledger is not edited. - **Message:** it names the hook and the function and gives the prescription before the explanation. It stays under the 500-character REST message bound when each name is shorter than about 65 characters. The measured text reads: "Invalid hook: 'scope_authored_cross' names the function 'x_stamp' in its `handler` and carries no `body`, so it can never run. Give it a `body` (sandboxed JS, `{ language: 'js', source }`, or an expression), which is stored with the hook. A hook saved through the metadata API ships with no code package, so it holds no functions, and a `handler` name resolves only inside the hook's own package." - **When:** in draft mode and in publish mode, before anything is stored or bound. - **Where in the door:** right after the type schema accepts the body, and before the runtime authoring gate and every write. H1 below explains the placement. The diff adds one module-level helper with its TSDoc, `runtimeHookWithoutBodyRefusal`, and one call site. ## Why such a hook can never bind (measured) - `ObjectQLPlugin`'s authored-hook re-sync binds every stored hook under the synthetic owner `metadata-service`, with no `functions` map. Both bind sites in `packages/objectql/src/plugin.ts` do this. - Since PR #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>
1 parent 83e2fee commit ced217c

4 files changed

Lines changed: 232 additions & 26 deletions

File tree

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 a hook whose `handler` names a function and that carries no `body`: a hook stored there ships with no code package, so that name can never bind
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at one runtime write door over an existing key: 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` records such a row canonical and writes nothing). The census of writers through this door, taken first: Studio at the objectui pin (`ab18797215`) creates a hook from a skeleton that carries a `body` and re-saves the body it lists (`ObjectHooksPanel`); objectstack `examples/**` and `packages/qa/**` declare no hook with a string `handler` (the only string `handler` there is a job's) and seed no `sys_metadata` hook rows; the platform checklist saves no such hook; 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`). Package duplication of a package holding such a row now reports that row as failed with this refusal (measured). 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 a `hook` whose `handler` is a function name and that carries no `body`. A hook stored through this door ships with no code package, so it holds no functions, and a `handler` name resolves only inside the hook's own package. Before this change the door answered 200, the runtime then refused the hook at bind (`INVALID_REFERENCE` / 400, in the server log only), and the hook 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 the function, and prescribes a `body`.
14+
15+
**Before and after** (with `{ name: 'stamp_status', object: 'crm_note', events: ['beforeInsert'], handler: 'x_stamp' }`):
16+
17+
- Before: 200 `Saved hook 'stamp_status'`, the row stored, the hook refused at bind and never run, and nothing on the response said so.
18+
- After: 400 `VALIDATION_ERROR`, naming `stamp_status` and `x_stamp`, and nothing stored.
19+
20+
**What still saves.** A hook with a `body`. A hook carrying both a `body` and a `handler`: the binder runs the body and never consults the name, and the install-local door accepts the same shape. A malformed `body` still gets the type schema's located `422 INVALID_METADATA`.
21+
22+
**What is unchanged.** `HookSchema` still accepts the string `handler`, because a build artifact carries it: `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 to its own package's functions 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 refuses them at bind 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.invalid-metadata-422-face-inventory.test.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,3 +492,81 @@ describe('[#21565] a hook body bound to a stored-metadata table is refused at th
492492
expect([...rows.values()].map((r) => [r.type, r.name])).toEqual([['hook', 'stamp_status']]);
493493
});
494494
});
495+
496+
// ═══════════════════════════════════════════════════════════════════════════
497+
// 7. #21658 — the `hook` door refuses a `handler` name with no `body`
498+
// ═══════════════════════════════════════════════════════════════════════════
499+
//
500+
// The same door as section 6, and the contrast to it: this refusal is NOT the
501+
// type schema's. `HookSchema` keeps accepting the string `handler`, because a
502+
// build artifact legitimately carries that form, and the artifact door never
503+
// reaches `saveMetaItem`. What this door stores ships with no code package,
504+
// and a `handler` name resolves only inside the hook's own package, so a hook
505+
// naming a function and carrying no `body` can never bind once stored. The
506+
// door refuses it with `VALIDATION_ERROR` / 400 — the envelope of the name
507+
// check every body passes — before anything is stored, in publish and in draft
508+
// mode. Rides this file's pinned engine double, as section 6 does.
509+
510+
describe('[#21658] a hook naming a function in `handler` with no `body` is refused at the metadata door', () => {
511+
const handlerOnly = () => ({
512+
name: 'stamp_status',
513+
object: 'hks_note',
514+
events: ['beforeInsert'],
515+
handler: 'x_stamp',
516+
});
517+
const body = { language: 'js', source: "ctx.input.status = 'seen';" };
518+
519+
it.each([
520+
['publish', undefined],
521+
['draft', 'draft'],
522+
] as const)('%s mode — VALIDATION_ERROR / 400, naming the hook and its handler, nothing stored', async (_label, mode) => {
523+
const { protocol, rows } = makeProtocol();
524+
let err: any;
525+
try {
526+
await protocol.saveMetaItem({
527+
type: 'hook',
528+
name: 'stamp_status',
529+
item: handlerOnly(),
530+
writeFace: 'meta-envelope',
531+
actor: 'usr_admin',
532+
...(mode ? { mode } : {}),
533+
});
534+
} catch (e) {
535+
err = e;
536+
}
537+
538+
expect(err).toBeInstanceOf(Error);
539+
expect({ code: err.code, status: err.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 });
540+
expect(err.message).toContain("'stamp_status'");
541+
expect(err.message).toContain("'x_stamp'");
542+
expect(err.message).toContain('Give it a `body`');
543+
expect(rows.size).toBe(0);
544+
});
545+
546+
it('CONTROL — the same hook with a `body` saves', async () => {
547+
const { protocol, rows } = makeProtocol();
548+
const { handler: _dropped, ...withoutHandler } = handlerOnly();
549+
const result = await saveHookAsAdministrator(protocol, { ...withoutHandler, body });
550+
551+
expect(result instanceof Error ? `${result.message} ${JSON.stringify((result as any).issues ?? [])}` : 'stored').toBe('stored');
552+
expect([...rows.values()].map((r) => [r.type, r.name])).toEqual([['hook', 'stamp_status']]);
553+
});
554+
555+
it('a `body` beside the `handler` saves: the binder runs the body and never consults the name', async () => {
556+
const { protocol, rows } = makeProtocol();
557+
const result = await saveHookAsAdministrator(protocol, { ...handlerOnly(), body });
558+
559+
expect(result instanceof Error ? `${result.message} ${JSON.stringify((result as any).issues ?? [])}` : 'stored').toBe('stored');
560+
expect([...rows.values()].map((r) => [r.type, r.name])).toEqual([['hook', 'stamp_status']]);
561+
});
562+
563+
it('a malformed `body` beside the `handler` gets the schema\'s located 422, not "give it a body"', async () => {
564+
const { protocol, rows } = makeProtocol();
565+
const err = await saveHookAsAdministrator(protocol, { ...handlerOnly(), body: 'return;' });
566+
567+
expect(err).toBeInstanceOf(Error);
568+
expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
569+
expect((err.issues as Array<{ path?: string }>).map((i) => i.path)).toContain('body');
570+
expect(rows.size).toBe(0);
571+
});
572+
});

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,70 @@ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null
779779
return getMetadataTypeSchema(singular) ?? null;
780780
}
781781

782+
/**
783+
* [#21658] The save door's refusal of a `hook` whose `handler` names a
784+
* function and that carries no `body`: such a hook can never run once this
785+
* door has stored it.
786+
*
787+
* Why it can never bind. A hook's `handler` name resolves inside the hook's
788+
* own package only (the maintainer's ruling on #21604, letter B; the binder's
789+
* `resolveHandler` in `@objectstack/objectql`'s `hook-binder.ts`). A hook this
790+
* door stores ships with no code package: the runtime binds every stored hook
791+
* under the synthetic owner `metadata-service` (`ObjectQLPlugin`'s authored
792+
* hook re-sync), with no `functions` map, and no package of that name
793+
* registers functions. So the name has nothing to resolve against, and the
794+
* binder refuses the hook at registration (`INVALID_REFERENCE` / 400, logged
795+
* at `error`) after this door has already answered success. Refusing it here
796+
* says so to the author, before anything is stored.
797+
*
798+
* The predicate is the binder's own body-first test: a `body` object is bound
799+
* through the body runner and the `handler` is never consulted, so a hook
800+
* carrying BOTH a `body` and a `handler` saves (its body runs), as it installs
801+
* on the install-local door. Asked after the type schema has accepted the
802+
* body, so `body` here is either absent or a declared hook body, and a
803+
* malformed `body` gets the schema's own located `422` instead of this
804+
* refusal's "give it a body".
805+
*
806+
* ⛔ Not a `HookSchema` rule: a build artifact legitimately carries the string
807+
* form (`objectstack build` lowers an inline function to the hook's name and
808+
* ships the function in the artifact's runtime module), and the artifact and
809+
* boot doors never reach `saveMetaItem`. This is the runtime-authoring door's
810+
* rule only, the same shape install-local refuses on its own door (#21585).
811+
*
812+
* Every writer through this door is judged: the REST and dispatcher saves, in
813+
* draft and in publish mode, and the two server-stated re-savers
814+
* (`migrateStoredMetadata`, `duplicatePackage`), which record this refusal as
815+
* the row's failure. A row stored before this rule keeps its bytes.
816+
*
817+
* `VALIDATION_ERROR` / 400, the envelope of the name check the door runs on
818+
* every body (`savedItemNameRefusal`). The message names the hook and its
819+
* `handler`, prescribes the `body` first, and only then explains: a 4xx
820+
* message crosses the REST boundary bounded at 500 characters with its TAIL
821+
* truncated, and the whole sentence stays under that bound for any hook and
822+
* function name shorter than about 65 characters each. Runtime words carry no
823+
* tracker number.
824+
*/
825+
function runtimeHookWithoutBodyRefusal(
826+
singularType: string,
827+
item: unknown,
828+
saveName: string,
829+
): (Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined {
830+
if (singularType !== 'hook') return undefined;
831+
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
832+
const hook = item as { handler?: unknown; body?: unknown };
833+
if (hook.body && typeof hook.body === 'object') return undefined;
834+
if (typeof hook.handler !== 'string' || hook.handler === '') return undefined;
835+
const err = new Error(
836+
`Invalid hook: '${saveName}' names the function '${hook.handler}' in its \`handler\` and carries no \`body\`, `
837+
+ 'so it can never run. Give it a `body` (sandboxed JS, `{ language: \'js\', source }`, or an expression), '
838+
+ 'which is stored with the hook. A hook saved through the metadata API ships with no code package, so it '
839+
+ "holds no functions, and a `handler` name resolves only inside the hook's own package.",
840+
) as Error & { code: 'VALIDATION_ERROR'; status: 400 };
841+
err.code = 'VALIDATION_ERROR';
842+
err.status = 400;
843+
return err;
844+
}
845+
782846
/**
783847
* One entry of the `422 INVALID_METADATA` envelope's `issues[]` — the shape
784848
* Studio's designer keys on to highlight the offending form control.
@@ -18815,6 +18879,17 @@ export class ObjectStackProtocolImplementation implements
1881518879
}
1881618880
}
1881718881

18882+
// [#21658] A hook whose `handler` names a function and that carries
18883+
// no `body` can never run once stored here: a stored hook ships with
18884+
// no code package, and a `handler` name resolves only inside the
18885+
// hook's own package. Refused in draft and in publish mode, after the
18886+
// schema (so `body` is absent or a declared body) and before the
18887+
// authoring gate and every write. See {@link runtimeHookWithoutBodyRefusal}.
18888+
{
18889+
const hookRefusal = runtimeHookWithoutBodyRefusal(singularType, request.item, request.name);
18890+
if (hookRefusal) throw hookRefusal;
18891+
}
18892+
1881818893
// The #4463 runtime authoring gate — the shared author-time rule
1881918894
// registry, on the write path. `active` saves only (D1): this is the
1882018895
// publish verb, and it is the same table `os build` gates on. Placed

0 commit comments

Comments
 (0)