Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/21565-hook-body-stored-metadata-target-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/spec': minor
---

A hook whose `body` targets a table of stored metadata, `sys_metadata` or `sys_metadata_history`, is refused at parse, with the runtime's prescription: change metadata through the metadata API.

Clause-②: yes (narrowing)

<!-- adr-0087: registered hook-body-stored-metadata-target-refused -->

**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.

**Why.** An app-authored body may not touch the two stored-metadata tables: for a body, the metadata protocol is their only writer, where a change is validated and its provenance is recorded. The runtime already enforces that where a body hook becomes a handler: such a hook is refused at registration and never runs. But `HookSchema` still accepted it, so the metadata save door answered 200 for a hook that would never fire, and the author learned otherwise only from a server log.

**What is refused.** A hook carrying a `body`, in any form, whose `object` names `sys_metadata` or `sys_metadata_history`, as the string or as any member of the list. One such member refuses the whole hook, as the runtime does. The issue's `code` is `custom`, at `object` (or `object.N` for a list member), and its message names the table and ends with the runtime's prescription. The membership test is the kernel's own `isStoredMetadataBodyObject`, the predicate the runtime judges by. That covers `HookSchema`, `defineHook()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `hooks.N.object`), `os validate`, which runs the same stack parse, an artifact's parse, and the metadata save door (`422 INVALID_METADATA`).

**What stays accepted, byte for byte.** A hook with no `body` on those tables (a code `handler`, which is how the platform writes its own hooks), a wildcard (`object: '*'`) hook with a `body` (it names neither table: the runtime binds it and never runs its body for those tables' events), and every hook on any other object.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| a hook with a `body` and `object: 'sys_metadata'` or `object: 'sys_metadata_history'` | change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`) instead, and delete the hook |
| a hook with a `body` whose `object` list includes either table | drop those tables from the list; change metadata through the metadata API instead |
| a hook with a `body` on `'*'` or on any other object | unchanged |

**The one-line fix: delete the hook, or remove `sys_metadata` and `sys_metadata_history` from its `object`, and make the change through the metadata API.** The runtime never ran such a hook, so removing it changes nothing an app does.

**Who is affected, measured.** No authored hook targets either table in this repository's `packages/**` and `examples/**` at `44072fc2b9` (317 hook-shaped declarations, 24 of them outside tests; the only hits are the runtime's own tests of its registration refusal) or in hotcrm at `94668373f2` (44 declarations, 40 outside tests, no hit). Deployed metadata was not measured. A stored hook row of this shape still loads, now with a `[metadata_spec_invalid]` warning and a `_diagnostics` badge, and is still never bound.

### The kit

- **The refusal.** An object-level check attached to `HookSchema` with `.superRefine(...)`. A schema derived from `HookSchema` by overriding a key must use `.safeExtend()`, which keeps the check; zod refuses `.extend()` over a refined object. The artifact-stage hook in `@objectstack/spec` now derives that way.
- **The ledger.** The D3 semantic entry `hook-body-stored-metadata-target-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: a refused hook carries no intent a rewrite could keep.
Original file line number Diff line number Diff line change
Expand Up @@ -428,3 +428,67 @@ describe('[#20161] a `joined` report\'s `chart` is refused at the metadata door'
expect([...rows.values()].map((r) => r.type)).toEqual(['report']);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// 6. #21565 — the `hook` door refuses a body bound to a stored-metadata table
// ═══════════════════════════════════════════════════════════════════════════
//
// An app-authored hook body may not be bound to `sys_metadata` or
// `sys_metadata_history`: the runtime refuses such a hook where a body becomes
// a handler (`hookBodyRunnerFactory`), so it never runs. The save door used to
// answer 200 for it. `HookSchema` now refuses it at parse, so THIS gate — the
// one `PUT /api/v1/meta/hook/:name` reaches, built here exactly as that route
// builds it for the administrator (`writeFace: 'meta-envelope'`, the actor
// named) — refuses it with the ADR-0112 envelope, the issue located at
// `object`, and the runtime's prescription. Rides this file's pinned engine
// double, as sections 4 and 5 do. ⛔ No check of its own lives in
// `protocol.ts`: the refusal is the registered type schema's.

async function saveHookAsAdministrator(protocol: any, item: Record<string, unknown>): Promise<any> {
try {
return await protocol.saveMetaItem({
type: 'hook',
name: item.name,
item,
writeFace: 'meta-envelope',
actor: 'usr_admin',
});
} catch (e: any) {
return e;
}
}

describe('[#21565] a hook body bound to a stored-metadata table is refused at the metadata door', () => {
const body = { language: 'js', source: "ctx.input.status = 'seen';" };
const hookOn = (object: string | string[]) => ({
name: 'stamp_status',
object,
events: ['beforeInsert'],
body,
});

it.each([
['sys_metadata', 'object'],
['sys_metadata_history', 'object'],
[['hks_note', 'sys_metadata'], 'object.1'],
] as const)('`object: %j` — 422 INVALID_METADATA at `%s`, with the prescription, nothing stored', async (object, path) => {
const { protocol, rows } = makeProtocol();
const err = await saveHookAsAdministrator(protocol, hookOn(object as string | string[]));

expect(err).toBeInstanceOf(Error);
expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
const issues = err.issues as Array<{ code?: string; path?: string; message: string }>;
expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', path]]);
expect(issues[0]!.message).toContain('a table of stored metadata');
expect(issues[0]!.message).toContain('Change metadata through the metadata API');
expect(rows.size).toBe(0);
});

it('CONTROL — the same body hook on an ordinary object saves as before', async () => {
const { protocol, rows } = makeProtocol();
const result = await saveHookAsAdministrator(protocol, hookOn('hks_note'));

expect(result instanceof Error ? `${result.message} ${JSON.stringify((result as any).issues ?? [])}` : 'stored').toBe('stored');
expect([...rows.values()].map((r) => [r.type, r.name])).toEqual([['hook', 'stamp_status']]);
});
});
7 changes: 6 additions & 1 deletion packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"measured": {
"zod": "4.4.3",
"publishedSchemasWithDroppedRefinements": 218,
"droppedRefinementSites": 654,
"droppedRefinementSites": 659,
"refinementSitesThatDidProject": 369,
"refinementSitesWithNoJsonFormToCompare": 0
},
Expand Down Expand Up @@ -80,6 +80,7 @@
"manifest.flows.element",
"manifest.flows.element.errorHandling",
"manifest.flows.element.nodes.element.in.waitEventConfig",
"manifest.hooks.element",
"manifest.hooks.element.object",
"manifest.jobs.element.schedule.options[0].timezone",
"manifest.navigationContributions.element.items.element.lazy.options[0]",
Expand Down Expand Up @@ -188,6 +189,7 @@
"data.options[1].manifest.flows.element",
"data.options[1].manifest.flows.element.errorHandling",
"data.options[1].manifest.flows.element.nodes.element.in.waitEventConfig",
"data.options[1].manifest.hooks.element",
"data.options[1].manifest.hooks.element.object",
"data.options[1].manifest.jobs.element.schedule.options[0].timezone",
"data.options[1].manifest.objectExtensions.element",
Expand Down Expand Up @@ -281,6 +283,7 @@
"options[1].manifest.flows.element",
"options[1].manifest.flows.element.errorHandling",
"options[1].manifest.flows.element.nodes.element.in.waitEventConfig",
"options[1].manifest.hooks.element",
"options[1].manifest.hooks.element.object",
"options[1].manifest.jobs.element.schedule.options[0].timezone",
"options[1].manifest.objectExtensions.element",
Expand Down Expand Up @@ -334,6 +337,7 @@
"data.packages.element.options[1].manifest.flows.element",
"data.packages.element.options[1].manifest.flows.element.errorHandling",
"data.packages.element.options[1].manifest.flows.element.nodes.element.in.waitEventConfig",
"data.packages.element.options[1].manifest.hooks.element",
"data.packages.element.options[1].manifest.hooks.element.object",
"data.packages.element.options[1].manifest.jobs.element.schedule.options[0].timezone",
"data.packages.element.options[1].manifest.objectExtensions.element",
Expand Down Expand Up @@ -721,6 +725,7 @@
},
"data/Hook": {
"sites": [
"",
"object"
]
},
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/export-origins/kernel.json
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@
"SEED_WRITE_EXECUTION_CONTEXT": "src/kernel/execution-context.zod.ts#SEED_WRITE_EXECUTION_CONTEXT (const)",
"SEMVER_2_0_0_VERSION_PATTERN": "src/kernel/version-grammar.ts#SEMVER_2_0_0_VERSION_PATTERN (const)",
"STORED_METADATA_BODY_COLUMN": "src/kernel/metadata-type-redaction.ts#STORED_METADATA_BODY_COLUMN (const)",
"STORED_METADATA_BODY_OBJECTS": "src/kernel/metadata-type-redaction.ts#STORED_METADATA_BODY_OBJECTS (const)",
"STORED_METADATA_BODY_OBJECTS": "src/kernel/stored-metadata-body-objects.ts#STORED_METADATA_BODY_OBJECTS (const)",
"STORED_METADATA_TYPE_COLUMN": "src/kernel/metadata-type-redaction.ts#STORED_METADATA_TYPE_COLUMN (const)",
"SandboxConfig": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfig (type)",
"SandboxConfigParsed": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfigParsed (type)",
Expand Down Expand Up @@ -462,7 +462,7 @@
"getMetadataTypeSchema": "src/kernel/metadata-type-schemas.ts#getMetadataTypeSchema (function)",
"isConsumerInstallable": "src/kernel/plugin.zod.ts#isConsumerInstallable (function)",
"isKnownPlatformCapability": "src/kernel/platform-capabilities.ts#isKnownPlatformCapability (function)",
"isStoredMetadataBodyObject": "src/kernel/metadata-type-redaction.ts#isStoredMetadataBodyObject (function)",
"isStoredMetadataBodyObject": "src/kernel/stored-metadata-body-objects.ts#isStoredMetadataBodyObject (function)",
"lintUnknownAuthoringKeys": "src/kernel/metadata-authoring-lint.ts#lintUnknownAuthoringKeys (function)",
"lintUnknownKeysAgainstSchema": "src/kernel/metadata-authoring-lint.ts#lintUnknownKeysAgainstSchema (function)",
"lintUnknownStackKeys": "src/kernel/metadata-authoring-lint.ts#lintUnknownStackKeys (function)",
Expand Down
Loading
Loading