diff --git a/.changeset/21689-hook-no-body-save-door.md b/.changeset/21689-hook-no-body-save-door.md new file mode 100644 index 00000000000..d9a3d766b7a --- /dev/null +++ b/.changeset/21689-hook-no-body-save-door.md @@ -0,0 +1,26 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +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 + +Clause-②: no (narrowing) + + + +**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. + +**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`. + +**Before and after** (with `{ name: 'stamp_status', object: 'crm_note', events: ['beforeInsert'] }`): + +- Before: 200 `Saved hook 'stamp_status'`, the row stored and served by name, and the hook never run. +- After: 400 `VALIDATION_ERROR`, naming `stamp_status`, and nothing stored. + +**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`. + +**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. + +**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. + +**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. diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index bf8c527b617..cbbbc013bf8 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -402,7 +402,10 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { const result = await protocol.saveMetaItem({ type: 'hook', name: 'rc3_probe_hook', - item: { name: 'rc3_probe_hook', object: 'task', events: ['beforeUpdate'] }, + // [#21689] A body-carrying hook: the save door refuses a hook + // with no `body` (it could never run), and this case measures + // the code-only gate, not that refusal. + item: { name: 'rc3_probe_hook', object: 'task', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } }, }); expect(result.success).toBe(true); expect(metaRows(rows).length).toBe(1); @@ -511,7 +514,9 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { }, { type: 'hook', // allowRuntimeCreate only - item: { name: 'rc3_receipt_view', object: 'task', events: ['beforeUpdate'] }, + // [#21689] With a `body`: the door refuses a hook without one, + // and this matrix measures the receipt, not that refusal. + item: { name: 'rc3_receipt_view', object: 'task', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } }, }, { type: 'webhook', // no static registry entry (plugin-registered) diff --git a/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts b/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts index 3fe60aae416..82c22672ff6 100644 --- a/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts +++ b/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts @@ -570,3 +570,50 @@ describe('[#21658] a hook naming a function in `handler` with no `body` is refus expect(rows.size).toBe(0); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// 8. #21689 — one predicate: the `hook` door refuses every hook with no `body` +// ═══════════════════════════════════════════════════════════════════════════ +// +// Section 7's refusal, generalised. A hook this door stores ships with no code +// package, so a `body` is the only code it can run: a hook with neither a +// `body` nor a `handler` is never bound either (the binder skips it at every +// re-sync). The door judges by one predicate — no `body` object — and section +// 7's `handler` form is one case of it, with the same envelope. An empty +// `handler` names no function, so it reads as no `handler`. + +describe('[#21689] a hook with no `body` and no function in `handler` is refused at the metadata door', () => { + const bare = (extra: Record = {}) => ({ + name: 'stamp_status', + object: 'hks_note', + events: ['beforeInsert'], + ...extra, + }); + + it.each([ + ['publish', 'neither field', undefined, {}], + ['draft', 'neither field', 'draft', {}], + ['publish', 'an empty `handler`', undefined, { handler: '' }], + ] as const)('%s mode, %s — VALIDATION_ERROR / 400, naming the hook, prescribing a `body`, nothing stored', async (_label, _shape, mode, extra) => { + const { protocol, rows } = makeProtocol(); + let err: any; + try { + await protocol.saveMetaItem({ + type: 'hook', + name: 'stamp_status', + item: bare(extra), + writeFace: 'meta-envelope', + actor: 'usr_admin', + ...(mode ? { mode } : {}), + }); + } catch (e) { + err = e; + } + + expect(err).toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + expect(err.message).toContain("'stamp_status'"); + expect(err.message).toContain('Give it a `body`'); + expect(rows.size).toBe(0); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts b/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts index b29cacf3b1a..bf319d23b0a 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-mint-door-agreement.test.ts @@ -143,7 +143,10 @@ const SAMPLE: Array<{ type: 'hook', klass: 'declared', creatable: true, - item: { name: 'probe_hook', object: 'task', events: ['beforeInsert'] }, + // [#21689] With a `body`: the mint door refuses a hook without one (it + // could never run), which would misread the advertisement this suite + // measures, as a 422 from schema resolution would. + item: { name: 'probe_hook', object: 'task', events: ['beforeInsert'], body: { language: 'js', source: 'return;' } }, }, { // The `false` direction of class 1, and it must be present: a listing diff --git a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts index 9edbbba88e3..0660f432947 100644 --- a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts +++ b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts @@ -129,7 +129,9 @@ const OVERLAYLESS_PROBES: Record> = { sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' } }, }, - hook: { name: 'rc5_acct', object: 'task', events: ['beforeUpdate'] }, + // [#21689] With a `body`: the door refuses a hook without one before the + // receipt is built, as it refuses a body the schema rejects. + hook: { name: 'rc5_acct', object: 'task', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } }, seed: { object: 'task', records: [] }, action: { name: 'rc5_acct', label: 'Convert', type: 'script', objectName: 'task', target: 'convertHandler' }, flow: { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 6fca322df1b..8115aebab95 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -781,34 +781,45 @@ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null } /** - * [#21658] The save door's refusal of a `hook` whose `handler` names a - * function and that carries no `body`: such a hook can never run once this - * door has stored it. - * - * Why it can never bind. A hook's `handler` name resolves inside the hook's - * own package only (the maintainer's ruling on #21604, letter B; the binder's - * `resolveHandler` in `@objectstack/objectql`'s `hook-binder.ts`). A hook this - * door stores ships with no code package: the runtime binds every stored hook - * under the synthetic owner `metadata-service` (`ObjectQLPlugin`'s authored - * hook re-sync), with no `functions` map, and no package of that name - * registers functions. So the name has nothing to resolve against, and the - * binder refuses the hook at registration (`INVALID_REFERENCE` / 400, logged - * at `error`) after this door has already answered success. Refusing it here - * says so to the author, before anything is stored. - * - * The predicate is the binder's own body-first test: a `body` object is bound - * through the body runner and the `handler` is never consulted, so a hook - * carrying BOTH a `body` and a `handler` saves (its body runs), as it installs - * on the install-local door. Asked after the type schema has accepted the - * body, so `body` here is either absent or a declared hook body, and a - * malformed `body` gets the schema's own located `422` instead of this - * refusal's "give it a body". + * [#21658, #21689] The save door's refusal of a `hook` that carries no `body`: + * such a hook can never run once this door has stored it. One predicate, two + * shapes it meets, one envelope: a hook whose `handler` names a function, and a + * hook with neither field. + * + * Why it can never run. A hook this door stores ships with no code package: + * the runtime binds every stored hook under the synthetic owner + * `metadata-service` (`ObjectQLPlugin`'s authored hook re-sync), with no + * `functions` map, and no package of that name registers functions. So a + * `body`, which is stored with the hook, is the only code it can run. + * + * - A `handler` name resolves inside the hook's own package only (the + * maintainer's ruling on #21604, letter B; the binder's `resolveHandler` in + * `@objectstack/objectql`'s `hook-binder.ts`), so it has nothing to resolve + * against, and the binder refuses the hook at registration + * (`INVALID_REFERENCE` / 400, logged at `error`). + * - A hook with neither field has nothing to bind at all, and the binder + * skips it at every re-sync (`skipping hook with unresolved handler`, + * logged at `warn`). + * + * Either way the door would already have answered success, and the hook would + * be served by name and never run. Refusing it here says so to the author, + * before anything is stored. + * + * The predicate is the binder's own body-first test, the judgement + * install-local's `collectHooksWithoutBody` makes on its own door (#21585): a + * `body` object is bound through the body runner and the `handler` is never + * consulted, so a hook carrying BOTH a `body` and a `handler` saves (its body + * runs), and every hook without a `body` object is refused, whatever its + * `handler` holds. Asked after the type schema has accepted the body, so + * `body` here is either absent or a declared hook body, and a malformed `body` + * gets the schema's own located `422` instead of this refusal's "give it a + * body". * * ⛔ Not a `HookSchema` rule: a build artifact legitimately carries the string * form (`objectstack build` lowers an inline function to the hook's name and * ships the function in the artifact's runtime module), and the artifact and * boot doors never reach `saveMetaItem`. This is the runtime-authoring door's - * rule only, the same shape install-local refuses on its own door (#21585). + * rule only. * * Every writer through this door is judged: the REST and dispatcher saves, in * draft and in publish mode, and the two server-stated re-savers @@ -816,12 +827,12 @@ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null * the row's failure. A row stored before this rule keeps its bytes. * * `VALIDATION_ERROR` / 400, the envelope of the name check the door runs on - * every body (`savedItemNameRefusal`). The message names the hook and its - * `handler`, prescribes the `body` first, and only then explains: a 4xx - * message crosses the REST boundary bounded at 500 characters with its TAIL - * truncated, and the whole sentence stays under that bound for any hook and - * function name shorter than about 65 characters each. Runtime words carry no - * tracker number. + * every body (`savedItemNameRefusal`). The message names the hook (and the + * function, when its `handler` names one), prescribes the `body` first, and + * only then explains: a 4xx message crosses the REST boundary bounded at 500 + * characters with its TAIL truncated, and the whole sentence stays under that + * bound for any hook and function name shorter than about 65 characters each. + * Runtime words carry no tracker number. */ function runtimeHookWithoutBodyRefusal( singularType: string, @@ -832,12 +843,15 @@ function runtimeHookWithoutBodyRefusal( if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; const hook = item as { handler?: unknown; body?: unknown }; if (hook.body && typeof hook.body === 'object') return undefined; - if (typeof hook.handler !== 'string' || hook.handler === '') return undefined; + const prescription = '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 '; const err = new Error( - `Invalid hook: '${saveName}' names the function '${hook.handler}' 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.", + typeof hook.handler === 'string' && hook.handler !== '' + ? `Invalid hook: '${saveName}' names the function '${hook.handler}' in its \`handler\` and carries no \`body\`, ` + + `so it can never run. ${prescription}it holds no functions, and a \`handler\` name resolves only inside ` + + "the hook's own package." + : `Invalid hook: '${saveName}' carries no \`body\`, so it has nothing to run. ${prescription}` + + 'its `body` is the only code it can run.', ) as Error & { code: 'VALIDATION_ERROR'; status: 400 }; err.code = 'VALIDATION_ERROR'; err.status = 400; @@ -18947,12 +18961,13 @@ export class ObjectStackProtocolImplementation implements } } - // [#21658] A hook whose `handler` names a function and that carries - // no `body` can never run once stored here: a stored hook ships with - // no code package, and a `handler` name resolves only inside the - // hook's own package. Refused in draft and in publish mode, after the - // schema (so `body` is absent or a declared body) and before the - // authoring gate and every write. See {@link runtimeHookWithoutBodyRefusal}. + // [#21658, #21689] A hook that carries no `body` can never run once + // stored here, whether its `handler` names a function or it has + // neither field: a stored hook ships with no code package, so its + // `body` is the only code it can run. Refused in draft and in publish + // mode, after the schema (so `body` is absent or a declared body) and + // before the authoring gate and every write. See + // {@link runtimeHookWithoutBodyRefusal}. { const hookRefusal = runtimeHookWithoutBodyRefusal(singularType, request.item, request.name); if (hookRefusal) throw hookRefusal; diff --git a/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts index 9a1205fa3b5..f69a9f3eb04 100644 --- a/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts +++ b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts @@ -215,7 +215,8 @@ describe('#8421 — the traffic that must keep working', () => { { type: 'hook', why: 'declared, runtime-create only', - item: { name: 'probe_item', object: 'task', events: ['beforeUpdate'] }, + // [#21689] With a `body`: the door refuses a hook without one. + item: { name: 'probe_item', object: 'task', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } }, }, { // `theme` held this slot until commit 35ad101bc retired the themes surface diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts index e4364c98c6a..6597884b294 100644 --- a/packages/objectql/src/metadata-validation-sweep.test.ts +++ b/packages/objectql/src/metadata-validation-sweep.test.ts @@ -143,12 +143,17 @@ const FIXTURES: Record = { invalidatedField: 'type', }, hook: { + // [#21689] Both carry a `body`: the save door refuses a hook with no + // `body` (it could never run), so a body-less `valid` would measure + // that refusal, and `invalid` stays `valid` minus the one field the + // schema must name. valid: { name: 'sweep_hook', object: 'sweep_account', events: ['beforeInsert'], + body: { language: 'js', source: 'return;' }, }, - invalid: { name: 'sweep_hook', object: 'sweep_account' }, + invalid: { name: 'sweep_hook', object: 'sweep_account', body: { language: 'js', source: 'return;' } }, invalidatedField: 'events', }, validation: { diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index 0bbd9f1876b..9e2346f69f8 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -269,8 +269,11 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { // `validation` left this list with the kind (#4509, ADR-0088): it is // no longer registered, so "runtime-creatable" no longer describes // it. The reintroduction guard below is what holds the line now. - { type: 'hook', item: { name: 'before_save', object: 'case', events: ['beforeInsert'] } }, - { type: 'hooks', item: { name: 'before_save', object: 'case', events: ['beforeInsert'] } }, // plural + // [#21689] Each hook carries a `body`: the save door refuses a hook + // with no `body` (it could never run), and this loop measures the + // two-tier verdict, not that refusal. + { type: 'hook', item: { name: 'before_save', object: 'case', events: ['beforeInsert'], body: { language: 'js', source: 'return;' } } }, + { type: 'hooks', item: { name: 'before_save', object: 'case', events: ['beforeInsert'], body: { language: 'js', source: 'return;' } } }, // plural // object reverted to allowOrgOverride:false on 2026-05-29 — // packaged items locked, brand-new tenant-authored items succeed. { @@ -349,7 +352,8 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { const result = await localProto.saveMetaItem({ type: 'hook', name: 'my_hook', - item: { name: 'my_hook', object: 'case', events: ['beforeUpdate'] }, + // [#21689] With a `body`: the door refuses a hook without one. + item: { name: 'my_hook', object: 'case', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } }, }); expect(result.success).toBe(true); }); diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 4d0bb5e4907..ead1cf4e339 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -1492,7 +1492,10 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { scoped.saveMetaItem({ type: 'hook', name: 'shipped_hook', - item: { name: 'shipped_hook', object: 'case', events: ['beforeInsert'] }, + // [#21689] A body-carrying hook, so the refusal measured is + // the provenance gate's and no other gate's: the door also + // refuses a hook with no `body`. + item: { name: 'shipped_hook', object: 'case', events: ['beforeInsert'], body: { language: 'js', source: 'return;' } }, organizationId: 'org_alpha', }), ).rejects.toMatchObject({ @@ -1507,7 +1510,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { const result = await scoped.saveMetaItem({ type: 'hook', name: 'my_user_hook', - item: { name: 'my_user_hook', object: 'case', events: ['beforeUpdate'] }, + // [#21689] With a `body`: the door refuses a hook without one. + item: { name: 'my_user_hook', object: 'case', events: ['beforeUpdate'], body: { language: 'js', source: 'return;' } }, }); expect(result.success).toBe(true); @@ -1529,7 +1533,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { const result = await scoped.saveMetaItem({ type: 'hook', name: 'my_user_hook', - item: { name: 'my_user_hook', object: 'case', events: ['beforeInsert', 'beforeUpdate'] }, + // [#21689] With a `body`: the door refuses a hook without one. + item: { name: 'my_user_hook', object: 'case', events: ['beforeInsert', 'beforeUpdate'], body: { language: 'js', source: 'return;' } }, }); expect(result.success).toBe(true); diff --git a/packages/runtime/src/hook-handler-package-scope.pin.test.ts b/packages/runtime/src/hook-handler-package-scope.pin.test.ts index 338f607f0f6..1cfa18a34a5 100644 --- a/packages/runtime/src/hook-handler-package-scope.pin.test.ts +++ b/packages/runtime/src/hook-handler-package-scope.pin.test.ts @@ -17,6 +17,15 @@ * so the name could only ever be refused again at bind (the binder's * `metadata-service` refusal, pinned in objectql's * `hook-binder-package-scope.test.ts`). + * ②c the same door, the other shape with no `body` (#21689): a hook with + * neither a `body` nor a `handler`, which the binder would skip at every + * re-sync, is refused by the same predicate with the same envelope. One + * predicate judges both shapes: a hook with no `body` object. + * + * The enumeration this file holds on the real door: a `body` hook saves (②b), + * a `handler`-only hook is refused (②), a hook with neither is refused (②c), + * and a built artifact's `handler` hook through its own door is unchanged + * (the X and Z controls). * * Controls, the two shapes a package's own function takes: app X's hook naming * 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[][] { return engineLogger.error.mock.calls.filter((call: any[]) => call[2]?.hook === hook); } +/** The binder's skips of `hook` (a hook with nothing to bind), as the engine logged them. */ +function skipsOf(hook: string): any[][] { + return engineLogger.warn.mock.calls.filter( + (call: any[]) => String(call[0]).includes('skipping hook') && call[1]?.hook === hook, + ); +} + async function waitFor(predicate: () => Promise, ms = 15_000): Promise { const until = Date.now() + ms; while (Date.now() < until) { @@ -248,6 +264,23 @@ describe('a hook handler name resolves inside its own package only — composed expect(stored.status, await stored.text()).toBe(404); }); + it('②c the metadata door refuses a runtime-authored hook with neither a `body` nor a `handler`: VALIDATION_ERROR / 400, nothing stored', async () => { + // The measured `PUT`: before #21689 it answered 200, the row was served by + // name, and the binder skipped it at every re-sync. + const bareHook = await asAdmin('PUT', '/meta/hook/scope_authored_bare', { + name: 'scope_authored_bare', + object: Y_NOTE, + events: ['beforeInsert'], + }); + const refusal: any = await bareHook.json(); + expect({ status: bareHook.status, code: refusal?.code }, JSON.stringify(refusal)) + .toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + expect(refusal.error).toContain("'scope_authored_bare'"); + expect(refusal.error).toContain('Give it a `body`'); + const stored = await asAdmin('GET', '/meta/hook/scope_authored_bare'); + expect(stored.status, await stored.text()).toBe(404); + }); + 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 () => { const bodyHook = await asAdmin('PUT', '/meta/hook/scope_authored_body', { name: 'scope_authored_body', @@ -279,9 +312,11 @@ describe('a hook handler name resolves inside its own package only — composed expect(refusalsOf('scope_authored_both')).toEqual([]); }, 30_000); - it('② nothing bound: once the re-sync has run (②b), the refused hook never reached the binder', async () => { + it('② and ②c nothing bound: once the re-sync has run (②b), neither refused hook reached the binder', async () => { // There was no row for the re-sync to bind, so the binder recorded no - // refusal of it either: the door refused it before anything was stored. + // refusal or skip of either: the door refused both before anything was stored. expect(refusalsOf('scope_authored_cross')).toEqual([]); + expect(refusalsOf('scope_authored_bare')).toEqual([]); + expect(skipsOf('scope_authored_bare')).toEqual([]); }); });