From b44d3ef395604f061c8de966da098160c76424e6 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:27:07 +0800 Subject: [PATCH 1/3] =?UTF-8?q?chore(spec):=20flip=20webhook=20liveness=20?= =?UTF-8?q?ledger=20dead=E2=86=92live=20+=20webhook-materialization=20proo?= =?UTF-8?q?f=20(#3490)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #3489. The materializer bridge made stack-authored webhooks reach the dispatcher, so the webhook liveness ledger (packages/spec/liveness/webhook.json) was stale — it still classified all 16 authorable props `dead` from the pre-bridge "nothing materializes an authored webhook" vantage point. Because `webhook` rides the SPEC_ONLY_SCHEMAS gate path (walk-only), the liveness CI did NOT catch the dead→live drift; this updates it by hand. - Flip the props the materializer + dispatcher consume to `live` with evidence (materializer bootstrap-declared-webhooks.ts + dispatcher auto-enqueuer.ts): object/isActive/url/triggers/method/name/headers/secret/timeoutMs, plus display-only label/description. Drop the `url` authorWarn (authoring is live). - Keep `dead`: body/payloadFields/includeSession/retryPolicy/tags — folded into definition_json by the bridge but parseRow never reads them (no consumer, the #1878 delivery-layer worklist). `authentication` stays experimental. - Add a `webhook-materialization` ADR-0054 high-risk proof class (proof-registry.mts, bound to webhook.object) + its dogfood proof: bootStack with the messaging + webhook plugins but NO realtime, asserting the sys_webhook row materializes with object→object_name / isActive→active. Pins the #3461 integration seam (the bridge was first gated behind the realtime dispatch guard, silently materializing nothing). - Refresh the stale "enforce-or-remove pending #3461" prose in check-liveness.mts and webhook.json's _note (the disconnect is closed by #3489). Verified: liveness gate green (webhook 17 classified: live 11 / dead 5 / exp 1; webhook-materialization proof resolves) and red-proofed (breaking the proof tag → exit 1); the dogfood proof passes (logs confirm hasRealtime:false yet the row materializes). Liveness-ledger + gate assets and the private dogfood package only — no package release. Co-Authored-By: Claude Fable 5 --- .changeset/webhook-liveness-ledger-flip.md | 23 ++++++ packages/qa/dogfood/package.json | 2 + .../webhook-materialization-fixture.ts | 59 +++++++++++++++ .../webhook-materialization.dogfood.test.ts | 70 ++++++++++++++++++ packages/spec/liveness/webhook.json | 73 +++++++++++-------- .../spec/scripts/liveness/check-liveness.mts | 11 ++- .../spec/scripts/liveness/proof-registry.mts | 14 ++++ pnpm-lock.yaml | 6 ++ 8 files changed, 222 insertions(+), 36 deletions(-) create mode 100644 .changeset/webhook-liveness-ledger-flip.md create mode 100644 packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts create mode 100644 packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts 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/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 fe50038c34..82512c5613 100644 --- a/packages/spec/liveness/webhook.json +++ b/packages/spec/liveness/webhook.json @@ -1,78 +1,87 @@ { "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:76). 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. BRIDGE LANDED (#3461 option A, PR #3489): the authoring surface is no longer inert. `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 the props the materializer + dispatcher actually consume are now LIVE. Props with NO runtime reader anywhere stay DEAD — note the nuance: the bridge folds the whole envelope into `definition_json`, so their values are now stored, but `parseRow` never reads them (auto-enqueuer.ts:255-278), so there is still no consumer (the #1878 delivery-layer enforce-or-remove worklist). `authentication` stays experimental (HMAC-`secret`-only; bearer/basic/api-key inert). 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." }, "body": { "status": "dead", - "note": "No sink anywhere. The dispatcher always sends its own fixed envelope (object/recordId/action/timestamp + full record, auto-enqueuer.ts:336-342); a custom body is never read. Vestigial even for a future materializer." + "note": "No consumer. The bridge folds it into definition_json (bootstrap-declared-webhooks.ts:203), but parseRow never reads a custom body and the dispatcher always sends its own fixed envelope (object/recordId/action/timestamp + full record, auto-enqueuer.ts:336-342). Enforce-or-remove (#1878)." }, "payloadFields": { "status": "dead", - "note": "No sink anywhere. No field projection — the full record is always sent (auto-enqueuer.ts:341). Never read." + "note": "No consumer. In definition_json now, but there is no field projection — the full record is always sent (auto-enqueuer.ts:341). Enforce-or-remove (#1878)." }, "includeSession": { "status": "dead", - "note": "No sink anywhere. No session is injected into the enqueue payload. Never read." + "note": "No consumer. In definition_json now, but no session is injected into the enqueue payload (auto-enqueuer.ts:336-342). Enforce-or-remove (#1878)." }, "authentication": { "status": "experimental", "authorHint": "Only HMAC signing via `secret` is applied to deliveries; bearer/basic/api-key credentials are never attached to outbound requests (liveness audit #1878/#1893).", - "note": "Self-marked [EXPERIMENTAL — not enforced] on the schema (webhook.zod.ts:102); pinned here so the compile lint warns on it (marker-only classifications aren't read by the lint). At runtime the delivery path applies HMAC via `secret` only (auto-enqueuer.ts:276) — bearer/basic/api-key are inert." + "note": "Self-marked [EXPERIMENTAL — not enforced] on the schema (webhook.zod.ts:102); pinned here so the compile lint warns on it. The bridge folds it into definition_json and `secret`-based HMAC is now applied (see the `secret` prop, live), but the bearer/basic/api-key credentials in this object are still never attached to outbound requests (auto-enqueuer.ts:276 signs with the secret only)." }, "retryPolicy": { "status": "dead", - "note": "No sink anywhere. Not present in EnqueueHttpInput (service-messaging/src/http-outbox.ts:87-98); the enqueuer passes no retry fields and the messaging HttpDispatcher owns a fixed retry schedule. Authored maxRetries/backoffStrategy/initialDelayMs/maxDelayMs are all ignored. (The showcase's TaskChangedWebhook authors a retryPolicy that does nothing — the canonical no-op.)" + "note": "No consumer. In definition_json now, but retry is not in EnqueueHttpInput (service-messaging/src/http-outbox.ts:87-98) — the messaging HttpDispatcher owns a fixed retry schedule; authored maxRetries/backoffStrategy/initialDelayMs/maxDelayMs are ignored. (The showcase's TaskChangedWebhook still authors a retryPolicy that does nothing.) Enforce-or-remove (#1878)." }, "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." }, "tags": { "status": "dead", - "note": "No sink anywhere. No sys_webhook column, never read. Vestigial." + "note": "No consumer. In definition_json now, but there is no sys_webhook column and nothing reads it. Vestigial. Enforce-or-remove (#1878)." } } } 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/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 From 6becad1148cb1b87a01c95b804b1f1c83063bc1d Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:43:54 +0800 Subject: [PATCH 2/3] test(spec): sync proof-registry test to the webhook-materialization bound class (#3490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry's BOUND_PROOF_PATHS expected-list and the real-proof-wiring `ledgerFor` map must be updated in lockstep when a bound high-risk class is added — add `webhook/object` and the `webhook` → webhook.json ledger path. The Test Core failure on the first push was exactly this lockstep assertion doing its job. (Validate Package Dependencies is red on OSV-Scanner audit hits for pre-existing transitive deps — next / react-router / sharp / tar / js-yaml / brace-expansion — none introduced here; that job is not a required check and is triggered only because this PR touches pnpm-lock.yaml to add the dogfood workspace deps.) Co-Authored-By: Claude Fable 5 --- packages/spec/scripts/liveness/proof-registry.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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 { From dde1a5c98d3d4791cd9aaea87f71a7beda9ee6e9 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:00:12 +0800 Subject: [PATCH 3/3] fix(spec,cli): redirect webhook authorWarn from now-live url to still-dead props (#3490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the Test Core lockstep the bound-class + ledger flip require. The bridge (#3489) made `url` live, so the CLI compile lint's per-webhook author warning must move OFF it onto the props that are STILL dead + misleading: retryPolicy/body/payloadFields/includeSession now carry `authorWarn` (folded into definition_json but never read — authored retry/body/field-projection/session do nothing). `tags` stays unmarked (benign display). `lint-liveness-properties.test.ts` asserts the new behavior: a webhook that authors only now-live props is silent, and the showcase-shaped webhook warns exactly once — on its inert `retryPolicy`, not on `url`. isActive test comment updated (it is live now, not "unmarked dead"). Verified: lint-liveness-properties.test.ts (17) + proof-registry.test.ts (23) green; liveness gate still green (webhook 17 classified, proofs resolve). --- .../utils/lint-liveness-properties.test.ts | 38 +++++++++---------- packages/spec/liveness/webhook.json | 10 ++++- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index 4fb2a32caa..a32fcfdbbc 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -159,27 +159,26 @@ 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', () => { + // ── webhook (#3461 bridge landed → #3490) ───────────────────────────────── + // The WebhookSchema authoring surface is now materialized into dispatchable + // sys_webhook rows on boot (#3489), so most props are live. The author warning + // moved OFF the now-live `url` carrier onto the props that are STILL dead and + // misleading — retryPolicy/body/payloadFields/includeSession (folded into + // definition_json but never read) — plus experimental `authentication`. These + // run against the REAL webhook.json ledger. + + it('does not warn on a webhook that only authors now-live props (#3490)', () => { const findings = lintLivenessProperties({ - webhooks: [{ name: 'w1', url: 'https://hooks.example/x' }], + webhooks: [{ name: 'w1', object: 'task', triggers: ['create'], url: 'https://hooks.example/x', method: 'POST', isActive: true }], }); - 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'); + expect(findings).toEqual([]); }); - it('emits exactly one warning for a fully-authored (showcase-shaped) webhook', () => { - // object/triggers/method/retryPolicy/isActive/description are all 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.) + it('emits exactly one warning for the showcase webhook — its still-dead retryPolicy', () => { + // object/triggers/url/method/isActive/description are live now; only + // retryPolicy is still dead + misleading (the messaging dispatcher owns a + // fixed retry schedule), so the showcase-shaped webhook yields ONE finding, + // not one-per-prop. const findings = lintLivenessProperties({ webhooks: [{ name: 'showcase_task_changed', @@ -193,7 +192,8 @@ describe('lintLivenessProperties', () => { }], }); expect(findings.length).toBe(1); - expect(findings[0].message).toContain('`url`'); + expect(findings[0].message).toContain('`retryPolicy`'); + expect(findings[0].where).toBe("webhook 'showcase_task_changed'"); }); it('also warns on authentication (experimental — HMAC-secret-only)', () => { @@ -203,7 +203,7 @@ describe('lintLivenessProperties', () => { expect(paths(findings).some((m) => m.includes('`authentication`'))).toBe(true); }); - it('does NOT warn on isActive (default(true) boolean, deliberately unmarked)', () => { + it('does NOT warn on isActive (live now — remapped to sys_webhook.active by the bridge)', () => { const findings = lintLivenessProperties({ webhooks: [{ name: 'w1', url: 'https://hooks.example/x', isActive: true }], }); diff --git a/packages/spec/liveness/webhook.json b/packages/spec/liveness/webhook.json index 82512c5613..91822a5f56 100644 --- a/packages/spec/liveness/webhook.json +++ b/packages/spec/liveness/webhook.json @@ -40,14 +40,20 @@ }, "body": { "status": "dead", + "authorWarn": true, + "authorHint": "The dispatcher always sends its own fixed envelope (object/recordId/action/timestamp + full record); a custom `body` is never sent (#1878).", "note": "No consumer. The bridge folds it into definition_json (bootstrap-declared-webhooks.ts:203), but parseRow never reads a custom body and the dispatcher always sends its own fixed envelope (object/recordId/action/timestamp + full record, auto-enqueuer.ts:336-342). Enforce-or-remove (#1878)." }, "payloadFields": { "status": "dead", + "authorWarn": true, + "authorHint": "No field projection — the full record is always sent; `payloadFields` is ignored (#1878).", "note": "No consumer. In definition_json now, but there is no field projection — the full record is always sent (auto-enqueuer.ts:341). Enforce-or-remove (#1878)." }, "includeSession": { "status": "dead", + "authorWarn": true, + "authorHint": "No session is injected into the webhook payload; `includeSession` is ignored (#1878).", "note": "No consumer. In definition_json now, but no session is injected into the enqueue payload (auto-enqueuer.ts:336-342). Enforce-or-remove (#1878)." }, "authentication": { @@ -57,7 +63,9 @@ }, "retryPolicy": { "status": "dead", - "note": "No consumer. In definition_json now, but retry is not in EnqueueHttpInput (service-messaging/src/http-outbox.ts:87-98) — the messaging HttpDispatcher owns a fixed retry schedule; authored maxRetries/backoffStrategy/initialDelayMs/maxDelayMs are ignored. (The showcase's TaskChangedWebhook still authors a retryPolicy that does nothing.) Enforce-or-remove (#1878)." + "authorWarn": true, + "authorHint": "The retry schedule is owned by the shared messaging HTTP dispatcher — authored maxRetries/backoffStrategy/initialDelayMs/maxDelayMs are ignored (#1878). Remove them until enforcement lands.", + "note": "No consumer. In definition_json now, but retry is not in EnqueueHttpInput (service-messaging/src/http-outbox.ts:87-98) — the messaging HttpDispatcher owns a fixed retry schedule; authored maxRetries/backoffStrategy/initialDelayMs/maxDelayMs are ignored. (The showcase's TaskChangedWebhook still authors a retryPolicy that does nothing.) Enforce-or-remove (#1878). Now that authoring is otherwise live, this is the misleading-dead prop that carries the author warning (previously `url` did, as the whole-artifact-dead heads-up)." }, "timeoutMs": { "status": "live",