diff --git a/.changeset/webhook-liveness-ledger-flip.md b/.changeset/webhook-liveness-ledger-flip.md new file mode 100644 index 0000000000..697f2da5a3 --- /dev/null +++ b/.changeset/webhook-liveness-ledger-flip.md @@ -0,0 +1,23 @@ +--- +--- + +chore(spec): flip webhook liveness ledger dead→live + webhook-materialization ADR-0054 proof (#3490) + +Follow-up to #3489 (the materializer bridge). Now that stack-authored webhooks +are materialized into dispatchable `sys_webhook` rows, the props the materializer ++ dispatcher actually consume flip from `dead` to `live` in +`packages/spec/liveness/webhook.json` (`object`/`isActive`/`url`/`triggers`/ +`method`/`name`/`headers`/`secret`/`timeoutMs` + display-only `label`/ +`description`); `body`/`payloadFields`/`includeSession`/`retryPolicy`/`tags` stay +`dead` (folded into `definition_json` but never read — the #1878 delivery-layer +worklist) and `authentication` stays `experimental`. The `url` authorWarn is +dropped (authoring is live now). + +Adds a `webhook-materialization` ADR-0054 high-risk proof class (bound to +`webhook.object`) with a `@objectstack/dogfood` proof that boots the real stack +WITHOUT realtime and asserts the row materializes — pinning the #3461 integration +seam (the bridge was first gated behind the realtime dispatch guard). + +Liveness-ledger + gate assets (`packages/spec/liveness` + `scripts/liveness`, not +in the published spec runtime) and the private `@objectstack/dogfood` package +only — no package release. diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index 34dbbbeecc..a03a1daf29 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -159,29 +159,15 @@ describe('lintLivenessProperties', () => { expect(findings).toEqual([]); }); - // ── webhook (#3462) ─────────────────────────────────────────────────────── - // The ENTIRE WebhookSchema authoring surface is disconnected from the - // sys_webhook dispatcher (#3461): authoring `webhooks:` never materializes a - // dispatchable row. Rather than warn on all 16 dead props, the ledger warns - // once per webhook via the required `url` carrier — one no-op heads-up per - // artifact. These run against the REAL webhook.json ledger. - - it('warns once per authored webhook via the url carrier', () => { - const findings = lintLivenessProperties({ - webhooks: [{ name: 'w1', url: 'https://hooks.example/x' }], - }); - const f = findings.find((x) => x.message.includes('`url`')); - expect(f).toBeDefined(); - expect(f!.where).toBe("webhook 'w1'"); - expect(f!.hint.toLowerCase()).toContain('sys_webhook'); - }); - - it('emits exactly one warning for a fully-authored (showcase-shaped) webhook', () => { - // object/triggers/method/isActive/description are dead too, but only `url` - // carries authorWarn — so the whole no-op artifact yields ONE finding, not - // one-per-prop. (isActive is default(true), deliberately unmarked. - // #3494 pruned the aspirational body/payloadFields/includeSession/ - // authentication/retryPolicy/tags props outright.) + // ── webhook (#3461 bridge landed → #3490; #3494 prune) ──────────────────── + // Two things closed the old "entire surface is dead" state: #3494 PRUNED the + // aspirational dead props (body/payloadFields/includeSession/retryPolicy/tags/ + // authentication) from the schema, and the #3489 materializer bridge makes + // every REMAINING prop live (object/isActive/url/triggers/method/name/headers/ + // secret/timeoutMs/label/description). So a webhook has no misleading dead + // surface left — authoring one is silent. Runs against the REAL webhook.json. + + it('does not warn on an authored webhook — all remaining props are live (#3490)', () => { const findings = lintLivenessProperties({ webhooks: [{ name: 'showcase_task_changed', @@ -193,14 +179,6 @@ describe('lintLivenessProperties', () => { description: 'Sends task lifecycle events to an external system.', }], }); - expect(findings.length).toBe(1); - expect(findings[0].message).toContain('`url`'); - }); - - it('does NOT warn on isActive (default(true) boolean, deliberately unmarked)', () => { - const findings = lintLivenessProperties({ - webhooks: [{ name: 'w1', url: 'https://hooks.example/x', isActive: true }], - }); - expect(paths(findings).some((m) => m.includes('`isActive`'))).toBe(false); + expect(findings).toEqual([]); }); }); diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index f261be8aad..213b23c9ad 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -19,6 +19,8 @@ "@objectstack/plugin-audit": "workspace:*", "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-security": "workspace:*", + "@objectstack/plugin-webhooks": "workspace:*", + "@objectstack/service-messaging": "workspace:*", "@objectstack/service-storage": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/verify": "workspace:*" diff --git a/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts b/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts new file mode 100644 index 0000000000..cb8469d044 --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Webhook materialization fixture — the deterministic ADR-0054 proof for the +// `webhook-materialization` high-risk class. +// +// A webhook prop is `live` in the ledger because a stack-authored `webhooks:` +// entry is materialized into a dispatchable `sys_webhook` row on boot +// (#3461/#3489) — but "materialized" is not "reads the schema". The authored +// value crosses manifest-decomposition → the ObjectQL registry (type `webhook`) +// → `bootstrapDeclaredWebhooks` → `engine.insert('sys_webhook')`, and the break +// can live in any seam: the bridge was first gated BEHIND the realtime/messaging +// dispatch guard, so a boot without realtime silently materialized nothing (the +// exact silent no-op #3461 set out to kill). This fixture authors ONE webhook +// against ONE object, with ZERO dependence on an example app, so the proof can +// assert the runtime outcome: the sys_webhook row exists with the spec→runtime +// remap applied (`object`→`object_name`, `isActive`→`active`). + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { defineWebhook } from '@objectstack/spec/automation'; + +/** The one object the authored webhook subscribes to. */ +export const WmTask = ObjectSchema.create({ + name: 'wm_task', + // [ADR-0090 D1] grandfather stamp: this fixture's gate under test is webhook + // materialization, not owner-sharing — keep RLS out of the way. + sharingModel: 'public_read_write', + label: 'WM Task', + pluralLabel: 'WM Tasks', + fields: { + name: Field.text({ label: 'Name', required: true }), + }, +}); + +/** + * The stack-authored webhook. `object` (spec) must land as `object_name` + * (runtime col) and `isActive` as `active` — the remap the proof asserts. + */ +export const wmTaskChanged = defineWebhook({ + name: 'wm_task_changed', + label: 'WM Task Changed', + object: 'wm_task', + triggers: ['create', 'update'], + url: 'https://hooks.example/wm-task', + isActive: true, +}); + +export const webhookFixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.webhook_fixture', + namespace: 'wm', + version: '0.0.0', + type: 'app', + name: 'Webhook Materialization Fixture', + description: 'Single-object app that authors one webhook to prove stack `webhooks:` entries materialize into dispatchable sys_webhook rows (ADR-0054, #3461).', + }, + objects: [WmTask], + webhooks: [wmTaskChanged], +}); diff --git a/packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts b/packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts new file mode 100644 index 0000000000..cf513b26a3 --- /dev/null +++ b/packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// WEBHOOK MATERIALIZATION proof (ADR-0054), exercised end-to-end through the +// real in-process stack. +// +// @proof: webhook-materialization +// ADR-0054 runtime proof for the webhook-materialization high-risk class. +// Referenced by the liveness ledger entry `webhook.object` +// (packages/spec/liveness/webhook.json); the spec liveness gate fails if this +// tag is removed. See proof-registry.mts. +// +// A webhook prop being `live` means the materializer + dispatcher READ it — +// necessary but not sufficient. This boots the real stack with the webhook + +// messaging plugins, authors a webhook via the app config's `webhooks:` array, +// and asserts the observable runtime outcome: a dispatchable `sys_webhook` row +// materialized on boot with the spec→runtime remap applied (`object`→ +// `object_name`, `isActive`→`active`). It also pins the #3461 integration bug: +// the fixture boots WITHOUT realtime, so a materializer re-gated behind the +// dispatch guard would materialize nothing and this proof would fail. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MessagingServicePlugin } from '@objectstack/service-messaging'; +import { WebhookOutboxPlugin } from '@objectstack/plugin-webhooks'; +import { webhookFixtureStack } from './fixtures/webhook-materialization-fixture.js'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +describe('objectstack verify WEBHOOK: authored webhook materializes into sys_webhook (#webhook-materialization)', () => { + let stack: VerifyStack; + + beforeAll(async () => { + // `extraPlugins` mirrors the capability plugins `objectstack serve` mounts + // for `requires: ['webhooks']` — messaging first (WebhookOutboxPlugin + // depends on it), then the webhook plugin. Deliberately NO realtime service: + // materialization must run on the data engine alone (bootDeclaredWebhooks), + // independent of the auto-enqueue dispatch prerequisites. + stack = await bootStack(webhookFixtureStack, { + extraPlugins: [new MessagingServicePlugin(), new WebhookOutboxPlugin()], + }); + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('materializes the stack-authored webhook into a sys_webhook row (object→object_name, isActive→active)', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const engine = (await stack.kernel.getServiceAsync('objectql')) as any; + const rows = await engine.find('sys_webhook', { + filter: { name: 'wm_task_changed' }, + context: SYSTEM_CTX, + }); + + expect(rows, 'authored webhook was NOT materialized into a sys_webhook row').toHaveLength(1); + const row = rows[0]; + + // The remaps that are the whole point of the bridge. + expect(row.object_name, 'spec `object` did not remap to runtime `object_name`').toBe('wm_task'); + expect(row.active, 'spec `isActive:true` did not remap to runtime `active`').toBeTruthy(); + + // Boot-seeded provenance (seed-not-clobber): a package row, not admin. + expect(row.managed_by).toBe('package'); + + // The full envelope is stashed for the dispatcher's advanced-config read. + const defn = JSON.parse(row.definition_json); + expect(defn.object).toBe('wm_task'); + expect(defn.triggers).toEqual(['create', 'update']); + }); +}); diff --git a/packages/spec/liveness/webhook.json b/packages/spec/liveness/webhook.json index 582c47a1c2..9188b0eb73 100644 --- a/packages/spec/liveness/webhook.json +++ b/packages/spec/liveness/webhook.json @@ -1,53 +1,62 @@ { "type": "webhook", - "_note": "WebhookSchema (outbound webhook — packages/spec/src/automation/webhook.zod.ts:76). Governed via a spec-only schema override in the gate (SPEC_ONLY_SCHEMAS): webhook is NOT a registered metadata type (absent from kernel/metadata-type-schemas.ts), on purpose — registering it would turn on Studio webhook CRUD + saveMetaItem overlay acceptance, which is the wrong move while the authoring surface is still disconnected (below). THE DISCONNECT (#3461, confirmed at HEAD): the entire authored WebhookSchema surface is inert. It's authored on a Stack (stack.zod.ts:261 `webhooks: z.array(WebhookSchema)`) and by connectors (connector.zod.ts:271), but NOTHING materializes an authored webhook into a `sys_webhook` DATA row — and the runtime dispatcher reads ONLY sys_webhook rows (AutoEnqueuer.doRefresh → engine.find('sys_webhook', { where: { active: true } }), plugins/plugin-webhooks/src/auto-enqueuer.ts:175-176). Rows are hand-created by admins via the generic object CRUD UI (sys-webhook.object.ts:45); there is no seeder and zero insert('sys_webhook') anywhere. THE TRAP: objectql DOES 'ingest' `webhooks:` (engine.ts:1183/1343) — registering each as a generic in-memory metadata item under type 'webhook' — but nothing ever reads those items back, and they are NOT the sys_webhook table. It looks like an ingestion path; it dead-ends. So every prop below is classified from the AUTHOR's vantage point (the ledger's stated purpose: an authored prop with no runtime consumer is a silent no-op), which for this surface means the whole thing: all 16 authorable props are DEAD, `authentication` stays experimental via its own marker. The per-prop notes record SALVAGEABILITY — which props have a sys_webhook column/delivery equivalent a future materializer (#3461 option A) could remap (spec `object`→`object_name`, `isActive`→`active`) vs which have no sink anywhere — so this doubles as the mapping table for that work. When the bridge lands, the mapped props flip to live (cite the materializer); if the surface is retired (#3461 option B) the ledger is removed with the schema. objectui has no bespoke consumer of the spec webhook shape. `url` carries the single author-warning per webhook (one no-op heads-up per artifact, not one per prop). Field-level line refs are the runtime sys_webhook object (plugins/plugin-webhooks/src/sys-webhook.object.ts) and its dispatcher (auto-enqueuer.ts).", + "_note": "WebhookSchema (outbound webhook — packages/spec/src/automation/webhook.zod.ts). Governed via a spec-only schema override in the gate (SPEC_ONLY_SCHEMAS): webhook is still NOT a registered metadata type (absent from kernel/metadata-type-schemas.ts) — registering it would turn on Studio webhook CRUD + saveMetaItem overlay + create-seeds; that reassessment is tracked in #3490 and deliberately deferred. TWO THINGS CLOSED THE OLD 'entire surface is dead' classification: (1) #3494 PRUNED the aspirational dead props outright — body / payloadFields / includeSession / retryPolicy / tags / authentication are gone from the schema; (2) the #3461 materializer bridge (PR #3489) makes every REMAINING prop live. `bootstrapDeclaredWebhooks` (plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts) materializes each stack/connector-authored webhook into a `sys_webhook` DATA row on boot — WebhookSchema.parse (:114) → mapWebhookToRow (:191): `object`→`object_name` (:195), `isActive`→`active` (:202), same-named `name`/`label`/`triggers`/`url`/`method`/`description`, and the FULL envelope → `definition_json` (:203). The dispatcher (AutoEnqueuer) reads those rows (auto-enqueuer.ts:175 `where:{active:true}`) and fans out on data.record.* events, reading `object_name`/`name`/`url`/`method`/`triggers` off the row and `headers`/`secret`/`timeoutMs` back out of `definition_json` (auto-enqueuer.ts:266-277). So all 11 remaining props are LIVE; there is no dead/experimental prop left, so nothing carries an authorWarn (the old per-webhook `url` heads-up is gone — authoring is no longer a no-op). Seed-not-clobber: an admin-edited row (`customized`) is never re-seeded (bootstrap-declared-webhooks.ts:143). The `object` prop carries the ADR-0054 runtime proof for the whole materialization pipeline (bound high-risk class `webhook-materialization`). Field-level line refs: materializer bootstrap-declared-webhooks.ts, runtime object plugins/plugin-webhooks/src/sys-webhook.object.ts, dispatcher auto-enqueuer.ts.", "props": { "name": { - "status": "dead", - "note": "Authored value never materialized. A sys_webhook.name column exists (sys-webhook.object.ts:97) and the dispatcher reads row.name (auto-enqueuer.ts:266) — but it's fed by admin CRUD, not by authoring. Salvageable 1:1 by a materializer." + "status": "live", + "evidence": "Materialized to sys_webhook.name by the bridge (bootstrap-declared-webhooks.ts:193, #3489); the dispatcher reads row.name for the dedup key + logging (auto-enqueuer.ts:266).", + "note": "Was dead pre-bridge (admin-CRUD-only). Now fed by authoring." }, "label": { - "status": "dead", - "note": "Authored value never materialized. Runtime col sys-webhook.object.ts:105 (Studio list display only; never sent on the wire). Salvageable 1:1." + "status": "live", + "evidence": "Materialized to sys_webhook.label (bootstrap-declared-webhooks.ts:194, #3489); consumed by the sys_webhook Studio list views (sys-webhook.object.ts listViews — `columns: ['label', …]`).", + "note": "Display-only — surfaced in Studio, never sent on the wire." }, "object": { - "status": "dead", - "note": "Authored value never materialized. Runtime subscription keys on col `object_name` (sys-webhook.object.ts:112), read at auto-enqueuer.ts:267 — NAME MISMATCH: spec `object` vs runtime `object_name`. A materializer must remap." + "status": "live", + "evidence": "Bridge remaps spec `object` → sys_webhook.object_name (bootstrap-declared-webhooks.ts:195, #3489); the dispatcher keys its per-object subscription on it (auto-enqueuer.ts:267).", + "proof": "packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts#webhook-materialization", + "note": "ADR-0054 high-risk class (webhook-materialization): the spec `object`→runtime `object_name` remap is the representative check for the whole authoring→dispatcher pipeline that crosses manifest-decomposition → materializer → sys_webhook row → dispatcher subscription — the exact multi-layer seam that broke in integration (the materializer was first gated behind the realtime guard) despite green unit tests. The proof authors a stack `webhooks:` entry against the real in-process stack and asserts the sys_webhook row materialized with object_name set." }, "triggers": { - "status": "dead", - "note": "Authored value never materialized. Runtime col sys-webhook.object.ts:123, read+mapped at auto-enqueuer.ts:212 (create/update/delete; unknown values dropped with a warning, #3196). Salvageable 1:1." + "status": "live", + "evidence": "Materialized to sys_webhook.triggers (bootstrap-declared-webhooks.ts:196, #3489); the dispatcher parses + maps them to create/update/delete (auto-enqueuer.ts:212, unknown values dropped with a warning #3196).", + "note": "Was dead pre-bridge. Salvaged 1:1." }, "url": { - "status": "dead", - "authorWarn": true, - "authorHint": "Stack/connector-authored webhooks are never materialized into the sys_webhook dispatcher — nothing delivers them (#3461). Create sys_webhook rows via the admin UI (or wait for the ingestion bridge) instead of authoring `webhooks:`.", - "note": "Authored value never materialized. Runtime col sys-webhook.object.ts:137, read at auto-enqueuer.ts:269. Salvageable 1:1. Marked authorWarn as the one-per-webhook carrier: `url` is required so it warns exactly once per authored webhook, flagging the whole artifact as a no-op without spamming a warning per prop." + "status": "live", + "evidence": "Materialized to sys_webhook.url (bootstrap-declared-webhooks.ts:197, #3489); the dispatcher delivers to it (auto-enqueuer.ts:269).", + "note": "Was dead pre-bridge and carried the per-webhook authorWarn; the bridge (#3489) makes authoring live, so the warning is removed." }, "method": { - "status": "dead", - "note": "Authored value never materialized. Runtime col sys-webhook.object.ts:145, read at auto-enqueuer.ts:274 (falls back to definition_json then 'POST'). Salvageable 1:1." + "status": "live", + "evidence": "Materialized (lowercased) to sys_webhook.method (bootstrap-declared-webhooks.ts:200, #3489); the dispatcher upper-cases + uses it (auto-enqueuer.ts:274).", + "note": "Was dead pre-bridge. Salvaged 1:1." }, "headers": { - "status": "dead", - "note": "Authored value never materialized. No first-class sys_webhook column — the dispatcher reads headers only from the `definition_json` textarea of a hand-created row (auto-enqueuer.ts:275). A materializer would fold this into definition_json." + "status": "live", + "evidence": "Bridge folds the full envelope into sys_webhook.definition_json (bootstrap-declared-webhooks.ts:203, #3489); the dispatcher reads defn.headers and attaches them to the outbound request (auto-enqueuer.ts:275).", + "note": "Was dead pre-bridge (definition_json only reachable via hand-authored rows). Now materialized from authoring." }, "timeoutMs": { - "status": "dead", - "note": "Authored value never materialized. Honored only from the `definition_json` textarea of a hand-created row (auto-enqueuer.ts:277 → http-outbox.ts:96), never from this top-level prop." + "status": "live", + "evidence": "Bridge folds the envelope into sys_webhook.definition_json (bootstrap-declared-webhooks.ts:203, #3489); the dispatcher reads defn.timeoutMs and applies it to the delivery (auto-enqueuer.ts:277 → http-outbox.ts:96).", + "note": "Was dead pre-bridge (definition_json only reachable via hand-authored rows). Now materialized from authoring." }, "secret": { - "status": "dead", - "note": "Authored value never materialized. Honored only from `definition_json` (auto-enqueuer.ts:276 → HMAC signingSecret at :334), never from this top-level prop." + "status": "live", + "evidence": "Bridge folds the envelope into sys_webhook.definition_json (bootstrap-declared-webhooks.ts:203, #3489); the dispatcher reads defn.secret and uses it as the HMAC signing secret (auto-enqueuer.ts:276 → signingSecret at :334).", + "note": "Was dead pre-bridge (definition_json only reachable via hand-authored rows). Now materialized from authoring." }, "isActive": { - "status": "dead", - "_authorWarnSkipped": "default(true) boolean — the lint can't distinguish author-set true from the schema default, so warning here would fire on every authored webhook regardless of intent (README rule 2). The `url` carrier already warns once per webhook.", - "note": "Authored value never materialized. The dispatcher gates on col `active` (sys-webhook.object.ts:162; where: { active: true } at auto-enqueuer.ts:176) — NAME MISMATCH: spec `isActive` vs runtime `active`. A materializer must remap." + "status": "live", + "evidence": "Bridge remaps spec `isActive` → sys_webhook.active (bootstrap-declared-webhooks.ts:202, #3489); the dispatcher gates delivery on it (`where: { active: true }`, auto-enqueuer.ts:176).", + "note": "Was dead pre-bridge (NAME MISMATCH spec `isActive` vs runtime `active`, now remapped). Seed-not-clobber preserves an admin's `active:false` across redeploys (bootstrap-declared-webhooks.ts:132-145)." }, "description": { - "status": "dead", - "note": "Authored value never materialized. Runtime col sys-webhook.object.ts:160 (Studio-editable; inert in dispatch). Salvageable 1:1." + "status": "live", + "evidence": "Materialized to sys_webhook.description (bootstrap-declared-webhooks.ts:201, #3489); Studio-editable/displayed (sys-webhook.object.ts:160).", + "note": "Display-only — surfaced/editable in Studio, inert in dispatch." } } } diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 9f004e6183..4bca6b2a92 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -63,10 +63,13 @@ const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'po // touch these, so nothing else notices when the spec surface goes stale. // // `webhook` is deliberately NOT registered: registering it would turn on Studio -// webhook CRUD + saveMetaItem overlay acceptance + diagnostics sweeping, which is -// the wrong move while the WebhookSchema authoring surface is still disconnected -// from the sys_webhook dispatcher (enforce-or-remove pending, #3461). The gate -// only needs to WALK the schema, not register it — so we resolve it directly. +// webhook CRUD + saveMetaItem overlay acceptance + diagnostics sweeping. The +// WebhookSchema authoring surface was disconnected from the sys_webhook +// dispatcher when this override landed (#3461/#3462); the materializer bridge +// has since closed that (#3489, and the mapped props are now `live` in +// webhook.json), and whether to fold `webhook` back onto the metadata-type +// registry is the reassessment tracked in #3490. The gate only needs to WALK +// the schema, not register it — so we resolve it directly. const SPEC_ONLY_SCHEMAS: Record = { webhook: WebhookSchema, }; diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index 577e022d13..2b03aaad7c 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -156,6 +156,20 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ // strip (forge dropped, sibling edit lands, insert exempt) over real HTTP. ledgerBindings: [{ type: 'field', path: 'readonly' }], }, + { + id: 'webhook-materialization', + label: 'Webhook materialization', + summary: + 'a stack/connector-authored webhook is materialized into a dispatchable sys_webhook row on boot (#3461/#3489) — the authored value crosses manifest-decomposition → the ObjectQL registry (type `webhook`) → the bridge → engine.insert, and the bridge was first gated BEHIND the realtime/messaging dispatch guard, silently materializing nothing on a realtime-less boot despite green unit tests.', + proofId: 'webhook-materialization', + proofRef: 'packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts#webhook-materialization', + bound: true, + // spec `object` → runtime `object_name` is the representative remap for the + // whole authoring→dispatcher pipeline: its `live` status is only true + // because the materializer runs (engine-only, independent of realtime), and + // the proof boots WITHOUT realtime to pin exactly that. + ledgerBindings: [{ type: 'webhook', path: 'object' }], + }, { id: 'form-widget', label: 'Form layout / section / widget', diff --git a/packages/spec/scripts/liveness/proof-registry.test.ts b/packages/spec/scripts/liveness/proof-registry.test.ts index 1ad3c13bb7..33a93bcbb1 100644 --- a/packages/spec/scripts/liveness/proof-registry.test.ts +++ b/packages/spec/scripts/liveness/proof-registry.test.ts @@ -112,6 +112,7 @@ describe('registry invariants', () => { 'object/sharingModel', 'position/delegatable', 'object/lifecycle', + 'webhook/object', ].sort(), ); }); @@ -133,6 +134,7 @@ describe('real proof wiring resolves', () => { dataset: 'packages/spec/liveness/dataset.json', object: 'packages/spec/liveness/object.json', position: 'packages/spec/liveness/position.json', + webhook: 'packages/spec/liveness/webhook.json', }; function ledgerEntry(type: string, path: string): any { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 833f3b8d38..6b6cdc7fd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1596,6 +1596,12 @@ importers: '@objectstack/plugin-security': specifier: workspace:* version: link:../../plugins/plugin-security + '@objectstack/plugin-webhooks': + specifier: workspace:* + version: link:../../plugins/plugin-webhooks + '@objectstack/service-messaging': + specifier: workspace:* + version: link:../../services/service-messaging '@objectstack/service-storage': specifier: workspace:* version: link:../../services/service-storage