Skip to content

Commit f3d2ea2

Browse files
committed
test: move the hook probes to body-carrying hooks and pin the neither-field refusal on the real door
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8bae5e8 commit f3d2ea2

9 files changed

Lines changed: 121 additions & 14 deletions

‎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.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: {

‎packages/objectql/src/overlay-precedence.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,11 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => {
269269
// `validation` left this list with the kind (#4509, ADR-0088): it is
270270
// no longer registered, so "runtime-creatable" no longer describes
271271
// it. The reintroduction guard below is what holds the line now.
272-
{ type: 'hook', item: { name: 'before_save', object: 'case', events: ['beforeInsert'] } },
273-
{ type: 'hooks', item: { name: 'before_save', object: 'case', events: ['beforeInsert'] } }, // plural
272+
// [#21689] Each hook carries a `body`: the save door refuses a hook
273+
// with no `body` (it could never run), and this loop measures the
274+
// two-tier verdict, not that refusal.
275+
{ type: 'hook', item: { name: 'before_save', object: 'case', events: ['beforeInsert'], body: { language: 'js', source: 'return;' } } },
276+
{ type: 'hooks', item: { name: 'before_save', object: 'case', events: ['beforeInsert'], body: { language: 'js', source: 'return;' } } }, // plural
274277
// object reverted to allowOrgOverride:false on 2026-05-29 —
275278
// packaged items locked, brand-new tenant-authored items succeed.
276279
{
@@ -349,7 +352,8 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => {
349352
const result = await localProto.saveMetaItem({
350353
type: 'hook',
351354
name: 'my_hook',
352-
item: { name: 'my_hook', object: 'case', events: ['beforeUpdate'] },
355+
// [#21689] With a `body`: the door refuses a hook without one.
356+
item: { name: 'my_hook', object: 'case', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } },
353357
});
354358
expect(result.success).toBe(true);
355359
});

‎packages/objectql/src/protocol-meta.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,7 +1492,10 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
14921492
scoped.saveMetaItem({
14931493
type: 'hook',
14941494
name: 'shipped_hook',
1495-
item: { name: 'shipped_hook', object: 'case', events: ['beforeInsert'] },
1495+
// [#21689] A body-carrying hook, so the refusal measured is
1496+
// the provenance gate's and no other gate's: the door also
1497+
// refuses a hook with no `body`.
1498+
item: { name: 'shipped_hook', object: 'case', events: ['beforeInsert'], body: { language: 'js', source: 'return;' } },
14961499
organizationId: 'org_alpha',
14971500
}),
14981501
).rejects.toMatchObject({
@@ -1507,7 +1510,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
15071510
const result = await scoped.saveMetaItem({
15081511
type: 'hook',
15091512
name: 'my_user_hook',
1510-
item: { name: 'my_user_hook', object: 'case', events: ['beforeUpdate'] },
1513+
// [#21689] With a `body`: the door refuses a hook without one.
1514+
item: { name: 'my_user_hook', object: 'case', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } },
15111515
});
15121516

15131517
expect(result.success).toBe(true);
@@ -1529,7 +1533,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
15291533
const result = await scoped.saveMetaItem({
15301534
type: 'hook',
15311535
name: 'my_user_hook',
1532-
item: { name: 'my_user_hook', object: 'case', events: ['beforeInsert', 'beforeUpdate'] },
1536+
// [#21689] With a `body`: the door refuses a hook without one.
1537+
item: { name: 'my_user_hook', object: 'case', events: ['beforeInsert', 'beforeUpdate'], body: { language: 'js', source: 'return;' } },
15331538
});
15341539

15351540
expect(result.success).toBe(true);

‎packages/runtime/src/hook-handler-package-scope.pin.test.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@
1717
* so the name could only ever be refused again at bind (the binder's
1818
* `metadata-service` refusal, pinned in objectql's
1919
* `hook-binder-package-scope.test.ts`).
20+
* ②c the same door, the other shape with no `body` (#21689): a hook with
21+
* neither a `body` nor a `handler`, which the binder would skip at every
22+
* re-sync, is refused by the same predicate with the same envelope. One
23+
* predicate judges both shapes: a hook with no `body` object.
24+
*
25+
* The enumeration this file holds on the real door: a `body` hook saves (②b),
26+
* a `handler`-only hook is refused (②), a hook with neither is refused (②c),
27+
* and a built artifact's `handler` hook through its own door is unchanged
28+
* (the X and Z controls).
2029
*
2130
* Controls, the two shapes a package's own function takes: app X's hook naming
2231
* X's own `functions` entry binds and runs, and app Z's hook naming a function
@@ -145,6 +154,13 @@ function refusalsOf(hook: string): any[][] {
145154
return engineLogger.error.mock.calls.filter((call: any[]) => call[2]?.hook === hook);
146155
}
147156

157+
/** The binder's skips of `hook` (a hook with nothing to bind), as the engine logged them. */
158+
function skipsOf(hook: string): any[][] {
159+
return engineLogger.warn.mock.calls.filter(
160+
(call: any[]) => String(call[0]).includes('skipping hook') && call[1]?.hook === hook,
161+
);
162+
}
163+
148164
async function waitFor(predicate: () => Promise<boolean>, ms = 15_000): Promise<boolean> {
149165
const until = Date.now() + ms;
150166
while (Date.now() < until) {
@@ -248,6 +264,23 @@ describe('a hook handler name resolves inside its own package only — composed
248264
expect(stored.status, await stored.text()).toBe(404);
249265
});
250266

267+
it('②c the metadata door refuses a runtime-authored hook with neither a `body` nor a `handler`: VALIDATION_ERROR / 400, nothing stored', async () => {
268+
// The measured `PUT`: before #21689 it answered 200, the row was served by
269+
// name, and the binder skipped it at every re-sync.
270+
const bareHook = await asAdmin('PUT', '/meta/hook/scope_authored_bare', {
271+
name: 'scope_authored_bare',
272+
object: Y_NOTE,
273+
events: ['beforeInsert'],
274+
});
275+
const refusal: any = await bareHook.json();
276+
expect({ status: bareHook.status, code: refusal?.code }, JSON.stringify(refusal))
277+
.toEqual({ status: 400, code: 'VALIDATION_ERROR' });
278+
expect(refusal.error).toContain("'scope_authored_bare'");
279+
expect(refusal.error).toContain('Give it a `body`');
280+
const stored = await asAdmin('GET', '/meta/hook/scope_authored_bare');
281+
expect(stored.status, await stored.text()).toBe(404);
282+
});
283+
251284
it('②b a body hook authored through the metadata door saves, binds and runs; so does one carrying both a `body` and a `handler`', async () => {
252285
const bodyHook = await asAdmin('PUT', '/meta/hook/scope_authored_body', {
253286
name: 'scope_authored_body',
@@ -279,9 +312,11 @@ describe('a hook handler name resolves inside its own package only — composed
279312
expect(refusalsOf('scope_authored_both')).toEqual([]);
280313
}, 30_000);
281314

282-
it('② nothing bound: once the re-sync has run (②b), the refused hook never reached the binder', async () => {
315+
it('② and ②c nothing bound: once the re-sync has run (②b), neither refused hook reached the binder', async () => {
283316
// There was no row for the re-sync to bind, so the binder recorded no
284-
// refusal of it either: the door refused it before anything was stored.
317+
// refusal or skip of either: the door refused both before anything was stored.
285318
expect(refusalsOf('scope_authored_cross')).toEqual([]);
319+
expect(refusalsOf('scope_authored_bare')).toEqual([]);
320+
expect(skipsOf('scope_authored_bare')).toEqual([]);
286321
});
287322
});

0 commit comments

Comments
 (0)