From ea9427033b17fd41fbb5dcf6d7b5326e135e41fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 09:33:43 +0000 Subject: [PATCH 1/2] docs(plugin-webhooks): the bridge's docblock names only the half it delivers, and a pin test holds the other half open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bootstrapDeclaredWebhooks` opened with "materialize stack/connector-declared `webhooks` into `sys_webhook` rows so the dispatcher can actually see them". The connector half was false, and false in the one sentence a reader trusts when deciding whether a connector's nested `webhooks[]` reaches the dispatcher. Measured against the real registration path, not grepped: `ObjectQL.registerApp` over a manifest declaring BOTH shapes registers `webhook` items for the top-level `webhooks:` collection only. The connector's nested `webhooks[]` stays inside the connector document — every registrar of the `webhook` type (`registerMetadataCollections`'s `METADATA_ARRAY_KEYS`, the artifact loader's collection map, the shared `PLURAL_TO_SINGULAR` spelling table) is keyed on a TOP-LEVEL collection name and none walks into a `connectors:` entry. The repair is the prose, not the code: `packages/spec` declares the connector surface unenforced twice (`automation/webhook.zod.ts`, and `WebhookConfigSchema` in `integration/connector.zod.ts`), so the docblock is the text out of step. A corrected sentence is still prose, so the claim it makes is now asserted by `bootstrap-declared-webhooks.connector-nested.test.ts` against a real ObjectQL boot, with an anti-vacuity control in the same corpus: a top-level `webhooks:` entry that IS registered and IS seeded. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF --- ...declared-webhooks.connector-nested.test.ts | 158 ++++++++++++++++++ .../src/bootstrap-declared-webhooks.test.ts | 16 +- .../src/bootstrap-declared-webhooks.ts | 34 +++- 3 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.connector-nested.test.ts diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.connector-nested.test.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.connector-nested.test.ts new file mode 100644 index 00000000000..006156573a2 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.connector-nested.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A connector's nested `webhooks[]` does NOT reach `sys_webhook` — pinned as + * behaviour, against a REAL registration path. + * + * ## Why this file exists + * + * `bootstrap-declared-webhooks.ts` used to open with "materialize + * stack/**connector**-declared `webhooks` into `sys_webhook` rows so the + * dispatcher can actually see them". The connector half was false, and it was + * false in the one place a reader looks when deciding whether a connector's + * nested `webhooks[]` reaches the dispatcher. Correcting the sentence is only + * half a fix: a corrected sentence is still prose, indistinguishable under grep + * from the false one it replaced, and it goes stale the same silent way. So the + * claim the new docblock makes is asserted here instead. + * + * ## What it asserts, and why it is not a grep + * + * The question is not "does the word `connector` appear near the word + * `webhook`" — that is exactly the instrument that cannot tell prose from + * behaviour. The question is "does the registration path file a connector's + * nested `webhooks[]` as a `webhook` METADATA ITEM", and it is answered by + * running the real `ObjectQL` boot registration over a manifest that declares + * both shapes at once. + * + * ⚠️ ANTI-VACUITY CONTROL, on the same subject and in the same corpus: the same + * manifest also declares a TOP-LEVEL `webhooks:` entry, and the same assertions + * demand that one IS registered and IS seeded. Without it, every expectation + * below would also pass on an engine that registered nothing at all, on a + * misspelled metadata type, or on a manifest this engine rejected outright. + * + * ## The direction this pins is the DECLARED one + * + * ⛔ A red here is not a licence to widen the seeder. `packages/spec` declares + * the connector surface unenforced in two places — `automation/webhook.zod.ts` + * ("(Connector `webhooks` remain NOT-yet-enforced — see #3197.)") and + * `WebhookConfigSchema` in `integration/connector.zod.ts` ("declared but + * ignored at registration ... parse and are stored, but no runtime dispatches, + * emits, or filters on them"). If #3197 ever builds that bridge, this file and + * the docblock it guards are part of that card's diff — which is the point: + * the prose can no longer drift out from under the behaviour on its own. + * + * ⚠️ `@objectstack/objectql` is NOT aliased to source by this package's + * `vitest.config.ts` — the ledger in `scripts/check-test-source-alias.mjs` + * records that — so it resolves through `exports` to `objectql/dist`. A stale + * objectql build therefore makes this verdict a statement about that build. + * `pnpm --filter '@objectstack/plugin-webhooks^...' build` first. + */ + +import { describe, expect, it } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; + +const PKG = 'com.acme.billing'; + +/** The anti-vacuity control: a webhook authored on the stack's OWN collection. */ +const STACK_DECLARED = { + name: 'stack_declared_hook', + label: 'Stack declared hook', + object: 'invoice', + triggers: ['create'], + url: 'https://example.invalid/stack', +}; + +/** The subject: a webhook authored INSIDE a connector document. */ +const CONNECTOR_NESTED = { + name: 'connector_nested_hook', + label: 'Connector nested hook', + object: 'invoice', + triggers: ['create'], + url: 'https://example.invalid/connector', +}; + +/** One manifest carrying BOTH shapes, so the two legs share a corpus. */ +function manifest(): Record { + return { + id: PKG, + name: 'billing', + webhooks: [{ ...STACK_DECLARED }], + connectors: [ + { + name: 'acme_erp', + label: 'Acme ERP', + type: 'rest', + webhooks: [{ ...CONNECTOR_NESTED }], + }, + ], + }; +} + +function bootedRegistry(): any { + const engine = new ObjectQL(); + (engine as any).registerApp(manifest()); + return (engine as any).registry; +} + +const namesOf = (registry: any, type: string): string[] => + (registry.listItems(type) ?? []).filter(Boolean).map((i: any) => i?.name); + +/** + * The minimum `IDataEngine` surface the seeder's INSERT path touches, wired to + * a real registry so `readDeclared` sees exactly what boot registered. No + * secret is authored, so no CryptoProvider is needed. + */ +function seedingEngine(registry: any) { + const rows: any[] = []; + return { + rows, + _registry: registry, + async find() { + return []; + }, + async insert(_object: string, row: any) { + rows.push(row); + return row; + }, + async update(_object: string, patch: any) { + return patch; + }, + }; +} + +describe("a connector's nested `webhooks[]` never becomes a `webhook` metadata item", () => { + it('registers the top-level collection and leaves the connector-nested one where it was authored', () => { + const registry = bootedRegistry(); + + // CONTROL — the same corpus, the same spelling convention, and it is not + // empty. This is what would have made the assertion below not-zero. + expect(namesOf(registry, 'webhook')).toContain(STACK_DECLARED.name); + + // SUBJECT. + expect(namesOf(registry, 'webhook')).not.toContain(CONNECTOR_NESTED.name); + expect(namesOf(registry, 'webhook')).toEqual([STACK_DECLARED.name]); + + // …and the zero is "not hoisted", not "dropped": the connector document is + // registered, and it still carries its nested `webhooks[]` verbatim. + expect(namesOf(registry, 'connector')).toEqual(['acme_erp']); + const connector: any = (registry.listItems('connector') ?? []).filter(Boolean)[0]; + expect(connector.webhooks?.map((w: any) => w?.name)).toEqual([CONNECTOR_NESTED.name]); + }); + + it('seeds `sys_webhook` from the top-level collection only — the connector half is not even skipped', async () => { + const engine = seedingEngine(bootedRegistry()); + + const result = await bootstrapDeclaredWebhooks(engine as any, undefined); + + // CONTROL: the bridge really did run and really did materialize. + expect(result.seeded).toBe(1); + expect(engine.rows.map((r) => r.name)).toEqual([STACK_DECLARED.name]); + + // SUBJECT: the connector-nested webhook produces NO row — and no `skipped` + // either, because the seeder never sees it at all. A future bridge that + // merely warned about it would move this number, not just the row list. + expect(engine.rows.map((r) => r.name)).not.toContain(CONNECTOR_NESTED.name); + expect(result.skipped).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts index c43105b0b81..3061a59a917 100644 --- a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts +++ b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts @@ -3,10 +3,18 @@ /** * bootstrapDeclaredWebhooks — the ingestion bridge that closes #3461. * - * Verifies that stack/connector-declared `webhook` metadata (spec shape: - * `object` / `isActive`) is materialized into `sys_webhook` data rows - * (`object_name` / `active` / `definition_json`), idempotently and without - * clobbering admin edits — and that the dispatcher then sees those rows. + * Verifies that STACK-declared `webhook` metadata (spec shape: `object` / + * `isActive`) is materialized into `sys_webhook` data rows (`object_name` / + * `active` / `definition_json`), idempotently and without clobbering admin + * edits — and that the dispatcher then sees those rows. + * + * This header used to say "stack/connector-declared", mirroring the same false + * clause the implementation's docblock carried: a connector's nested + * `webhooks[]` never becomes a `webhook` metadata item and so never reaches + * this bridge at all. Nothing in THIS file ever exercised a connector — every + * fixture below seeds `declared.webhook` directly — so the word was a claim the + * suite did not make. The claim it does not make is measured against a real + * engine next door, in `bootstrap-declared-webhooks.connector-nested.test.ts`. */ import { describe, expect, it, vi } from 'vitest'; diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts index a4669d930a2..5b77bd73b6a 100644 --- a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts +++ b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts @@ -1,8 +1,38 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * bootstrapDeclaredWebhooks — materialize stack/connector-declared `webhooks` - * into `sys_webhook` rows so the dispatcher can actually see them (closes #3461). + * bootstrapDeclaredWebhooks — materialize STACK-declared `webhooks` + * (`defineStack({ webhooks })`) into `sys_webhook` rows so the dispatcher can + * actually see them (closes #3461 for that half — see the next section for the + * half it does not reach). + * + * ## What this path does NOT reach: a connector's nested `webhooks[]` + * This opening sentence used to say "stack/connector-declared", and the + * connector half of it was false. The function's ONE source is + * `readDeclared(engine, metadataService, 'webhook')` — `webhook` METADATA + * ITEMS — and every registrar of that type is keyed on the TOP-LEVEL + * `webhooks:` collection of a manifest / nested-plugin document: + * `registerMetadataCollections` enumerates `METADATA_ARRAY_KEYS` over that + * document (objectql `engine.ts`), and the artifact loader's collection map + * (`packages/metadata/src/plugin.ts`) reads the same spelling table + * (`PLURAL_TO_SINGULAR`, `@objectstack/spec/meta-spelling`). None of them walks + * INTO a `connectors:` entry. A connector's nested `webhooks[]` therefore stays + * inside the connector document, never becomes a `webhook` metadata item, is + * never in `declared`, and is never materialized here — no row, no warning, no + * skip count. The shapes are not even the same: a connector's entries are + * `WebhookConfigSchema` (`WebhookSchema` plus `events` / `signatureAlgorithm`), + * not the `WebhookSchema` this seeder parses. + * + * That gap is the DECLARED direction, not an oversight to route around. The + * spec says so twice: `@objectstack/spec/automation/webhook` — "(Connector + * `webhooks` remain NOT-yet-enforced — see #3197.)" — and the nested schema + * itself (`WebhookConfigSchema`, `integration/connector.zod.ts`) — "declared + * but ignored at registration ... parse and are stored, but no runtime + * dispatches, emits, or filters on them". Widening this seeder to read + * connectors would enforce a surface the spec declares unenforced; that is + * #3197's card, not this one. The absence is pinned as behaviour by + * `bootstrap-declared-webhooks.connector-nested.test.ts`, so this paragraph + * cannot quietly go stale the way the sentence above it did. * * ## The disconnect this closes * The spec authoring surface (`WebhookSchema` — `defineStack({ webhooks })`, From d75d57ae20f44abd571116ed95f752c7ed6e864a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 09:51:12 +0000 Subject: [PATCH 2/2] test(plugin-webhooks): the pin's engine double stops where the seeder's path stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:engine-double-contract` pinned the new fake's `update()`: a double looser than `ObjectQL.update` is how a dead route ships with a green suite. The verb is not stubbed-and-asserted but removed — `find` always answers empty in this file, so the seeder always takes the INSERT branch and never dispatches an update. A fake that stops where the path stops has nothing to be looser than, and a future change that DID reach the update branch now fails loudly here instead of passing against a permissive stub. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF --- ...otstrap-declared-webhooks.connector-nested.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.connector-nested.test.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.connector-nested.test.ts index 006156573a2..664789ceb99 100644 --- a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.connector-nested.test.ts +++ b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.connector-nested.test.ts @@ -102,6 +102,14 @@ const namesOf = (registry: any, type: string): string[] => * The minimum `IDataEngine` surface the seeder's INSERT path touches, wired to * a real registry so `readDeclared` sees exactly what boot registered. No * secret is authored, so no CryptoProvider is needed. + * + * `update` is deliberately ABSENT rather than stubbed. `find` always answers + * empty here, so the seeder always takes the INSERT branch and never dispatches + * an update; a stub for it would be an engine double declaring a verb this file + * does not exercise, which `check:engine-double-contract` correctly treats as + * new surface to pin. A fake that stops where the path stops has nothing to be + * looser than, and a future change that DID reach the update branch would fail + * loudly here instead of passing against a permissive stub. */ function seedingEngine(registry: any) { const rows: any[] = []; @@ -115,9 +123,6 @@ function seedingEngine(registry: any) { rows.push(row); return row; }, - async update(_object: string, patch: any) { - return patch; - }, }; }