From b98716d0efd2e52289e589b856bc5058b9536eba Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:44:33 +0800 Subject: [PATCH] chore(spec,cli): enroll webhook in the liveness GOVERNED set (#3462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the final third of #3462 (umbrella #1878). report/dashboard landed in #3474; webhook was deferred for two reasons, both handled here. - Not a registered metadata type: webhook is absent from the metadata-type registry, so the gate can't resolve it via getMetadataTypeSchema. Registering it would switch on Studio webhook CRUD + saveMetaItem overlay acceptance + diagnostics sweeping — wrong while the authoring surface is still disconnected. Instead the gate walks it via a small SPEC_ONLY_SCHEMAS override in check-liveness.mts (consulted before the registry). Zero runtime blast radius. - The whole authoring surface is dead (#3461, confirmed at HEAD): nothing materializes an authored webhooks: entry (stack/connector) into a sys_webhook dispatcher row; the runtime reads only admin-authored sys_webhook rows. So liveness/webhook.json classifies all 16 authorable props dead + authentication experimental (HMAC-secret-only, its existing marker). Per-prop notes record the sys_webhook column map as the future materializer's mapping table (#3461 A) and flag the object->object_name / isActive->active mismatches. Also documents the objectql engine.ts:1183/1343 dead-end that registers webhooks: as inert metadata items nothing reads back — the trap that makes this look connected. - Author-warning wired (@objectstack/cli): added { type: 'webhook', key: 'webhooks' } to TYPE_COLLECTIONS so os compile now advises that webhooks: is a silent no-op. The required url prop carries the single warning per webhook (one heads-up per artifact, not one per dead prop); isActive left unmarked (default(true) boolean). The showcase's TaskChangedWebhook is the live example. Enrollment only — does not decide #3461's build-the-bridge vs retire-the-surface question. Gate green (webhook 17 classified: dead 16, experimental 1); lint contract test red->green; spec 6857 + cli 640 tests pass. No spec shape/behavior change (ledger + gate/lint config only). Co-Authored-By: Claude Fable 5 --- .changeset/govern-webhook-liveness.md | 36 +++++++++ .../utils/lint-liveness-properties.test.ts | 51 ++++++++++++ .../cli/src/utils/lint-liveness-properties.ts | 1 + packages/spec/liveness/README.md | 14 +++- packages/spec/liveness/webhook.json | 78 +++++++++++++++++++ .../spec/scripts/liveness/check-liveness.mts | 20 ++++- 6 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 .changeset/govern-webhook-liveness.md create mode 100644 packages/spec/liveness/webhook.json diff --git a/.changeset/govern-webhook-liveness.md b/.changeset/govern-webhook-liveness.md new file mode 100644 index 000000000..ba0ae7bdb --- /dev/null +++ b/.changeset/govern-webhook-liveness.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": patch +"@objectstack/cli": patch +--- + +chore(spec,cli): enroll `webhook` in the liveness GOVERNED set (#3462) + +Closes the final third of #3462 (umbrella #1878) — `report` and `dashboard` +landed in #3474; `webhook` was deferred for two reasons, both handled here. + +- **Not a registered metadata type.** `webhook` is absent from the metadata-type + registry, so the gate can't resolve it via `getMetadataTypeSchema`. Registering + it would switch on Studio webhook CRUD, `saveMetaItem` overlay acceptance, and + diagnostics sweeping — the wrong move while the authoring surface is still + disconnected (below). Instead the gate resolves it through a small + `SPEC_ONLY_SCHEMAS` override in `check-liveness.mts` (consulted before the + registry): the gate only needs to **walk** the schema, not register it. +- **The whole authoring surface is dead (#3461).** Nothing materializes an + authored `webhooks:` entry (stack/connector) into a `sys_webhook` dispatcher + row — the runtime reads only admin-authored `sys_webhook` rows. So + `packages/spec/liveness/webhook.json` classifies all 16 authorable props + **dead** and `authentication` **experimental** (HMAC-`secret`-only, its + existing marker). Per-prop notes record which props a future materializer + (#3461 option A) could remap (e.g. `object`→`object_name`, `isActive`→`active`) + vs which have no sink anywhere — doubling as that mapping table. +- **Author-warning wired (`@objectstack/cli`).** Added + `{ type: 'webhook', key: 'webhooks' }` to `TYPE_COLLECTIONS` in + `lint-liveness-properties.ts`, so `os compile` now advises authors that + `webhooks:` is a silent no-op. The required `url` prop carries the single + warning per webhook (one heads-up per artifact, not one per dead prop); + `isActive` is left unmarked (default(true) boolean). + +This is enrollment only — it does **not** decide #3461's build-the-bridge vs +retire-the-surface question. When that lands, the mapped props flip to live (cite +the materializer) or the ledger is removed with the schema. No spec shape/behavior +change (ledger + gate/lint config only). diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index 38afbb0e4..4fb2a32ca 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -158,4 +158,55 @@ 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/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.) + const findings = lintLivenessProperties({ + webhooks: [{ + name: 'showcase_task_changed', + object: 'showcase_task', + triggers: ['create', 'update', 'delete'], + url: 'https://hooks.example/showcase/task', + method: 'POST', + retryPolicy: { maxRetries: 3, backoffStrategy: 'exponential' }, + isActive: true, + description: 'Sends task lifecycle events to an external system.', + }], + }); + expect(findings.length).toBe(1); + expect(findings[0].message).toContain('`url`'); + }); + + it('also warns on authentication (experimental — HMAC-secret-only)', () => { + const findings = lintLivenessProperties({ + webhooks: [{ name: 'w1', url: 'https://hooks.example/x', authentication: { type: 'bearer' } }], + }); + expect(paths(findings).some((m) => m.includes('`authentication`'))).toBe(true); + }); + + 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); + }); }); diff --git a/packages/cli/src/utils/lint-liveness-properties.ts b/packages/cli/src/utils/lint-liveness-properties.ts index 99abbf197..b835b874d 100644 --- a/packages/cli/src/utils/lint-liveness-properties.ts +++ b/packages/cli/src/utils/lint-liveness-properties.ts @@ -181,6 +181,7 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [ { type: 'hook', key: 'hooks' }, { type: 'page', key: 'pages' }, { type: 'view', key: 'views' }, + { type: 'webhook', key: 'webhooks' }, ]; /** diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 203bf3f06..6106a957c 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -23,6 +23,17 @@ This matters: the older gate read the generated `json-schema/` directory, which most top-level authorable types (object/field/flow/action/...) — so it was blind to the core surface. The registry is complete. +**Spec-only exception (`SPEC_ONLY_SCHEMAS`).** A type can be authorable yet deliberately +*not* registered — `webhook` is the case: its schema is authored on a Stack/connector but +registering it as a metadata type would switch on Studio webhook CRUD, `saveMetaItem` +overlay acceptance, and diagnostics sweeping, which is wrong while the surface is still +disconnected from the `sys_webhook` dispatcher (#3461). Since being off the registry is +*itself* how such a drift hides, the gate resolves these through a small +`SPEC_ONLY_SCHEMAS` override in `check-liveness.mts` (consulted before +`getMetadataTypeSchema`) — it only needs to **walk** the schema, not register it. When a +disconnect like `webhook`'s is resolved (materializer built or surface retired), fold the +type back onto the registry and drop the override. + ## Status vocabulary | Status | Meaning | @@ -166,7 +177,7 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type RecordDetailView had been gating the History tab on it the whole time (#2707). 4. Add the type to `GOVERNED`; confirm the gate is green. -## Current state — 13 governed types +## Current state — 16 governed types Counts include drilled `children` entries; regenerate with the snippet below rather than hand-editing (this table drifted badly once — field was listed 34/39 while the @@ -205,6 +216,7 @@ EOF | report | 20 | 0 | 2 | – | dataset-bound (ADR-0021); dead = aria + performance (perf authorWarn'd); audit-era `chart` DEAD superseded — DatasetReportChart plots `chart.xAxis`/`yAxis` via useDatasetRows (framework#1890 / #3441), `groupBy` stays experimental (describe marker) | | dashboard | 18 | 0 | 2 | – | ADR-0021 dataset widgets (WidgetConfigPanel + DashboardRenderer migrated #3251; DashboardWidgetSchema `.strict()`); dead = aria + performance (perf authorWarn'd); audit-era `globalFilters`/`dateRange` DEAD superseded — LIVE via framework#2501; `title`↔`label` fixed (objectui#2806) | +| webhook | 0 | 1 | 16 | – | **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema` (#3461/#3462). The ENTIRE authoring surface is dead: nothing materializes an authored `webhooks:` entry into a `sys_webhook` dispatcher row (#3461, enforce-or-remove pending). `url` carries the single per-webhook `authorWarn` (one no-op heads-up per artifact, not per-prop); `authentication` experimental (HMAC-`secret`-only); `isActive` unmarked (default(true)). Notes cite the sys_webhook column map as the future materializer's mapping table | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every misleading entry carries `authorWarn` so authors hear about it at compile time. diff --git a/packages/spec/liveness/webhook.json b/packages/spec/liveness/webhook.json new file mode 100644 index 000000000..fe50038c3 --- /dev/null +++ b/packages/spec/liveness/webhook.json @@ -0,0 +1,78 @@ +{ + "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).", + "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." + }, + "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." + }, + "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." + }, + "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." + }, + "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." + }, + "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." + }, + "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." + }, + "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." + }, + "payloadFields": { + "status": "dead", + "note": "No sink anywhere. No field projection — the full record is always sent (auto-enqueuer.ts:341). Never read." + }, + "includeSession": { + "status": "dead", + "note": "No sink anywhere. No session is injected into the enqueue payload. Never read." + }, + "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." + }, + "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.)" + }, + "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." + }, + "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." + }, + "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." + }, + "description": { + "status": "dead", + "note": "Authored value never materialized. Runtime col sys-webhook.object.ts:160 (Studio-editable; inert in dispatch). Salvageable 1:1." + }, + "tags": { + "status": "dead", + "note": "No sink anywhere. No sys_webhook column, never read. Vestigial." + } + } +} diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 00e7a49d2..9f004e618 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -38,6 +38,7 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '../../src/kernel/metadata-type-schemas'; +import { WebhookSchema } from '../../src/automation/webhook.zod'; import { BOUND_PROOF_PATHS, HIGH_RISK_CLASSES, @@ -53,7 +54,22 @@ const repoRoot = resolve(specRoot, '../..'); const ledgerRoot = join(specRoot, 'liveness'); // Governed metadata types, rolled out highest-frequency / highest-risk first. -const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook']; + +// Spec-only override: governed types whose canonical schema is NOT (yet) in the +// metadata-type registry, so they can't be resolved via getMetadataTypeSchema. +// The ledger still governs them — being off the registry is exactly why a drift +// can survive: neither the runtime /meta/types endpoint nor Studio's admin forms +// 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. +const SPEC_ONLY_SCHEMAS: Record = { + webhook: WebhookSchema, +}; // ADR-0010 provenance/lock overlay fields — system-stamped, on every type; auto-live. const FRAMEWORK_FIELDS = new Set([ @@ -126,7 +142,7 @@ function childShape(s: any): Record | null { } function topProps(type: string): Array<{ key: string; node: any; description: string }> { - const schema = getMetadataTypeSchema(type); + const schema = SPEC_ONLY_SCHEMAS[type] ?? getMetadataTypeSchema(type); if (!schema) throw new Error(`metadata type '${type}' has no registered schema`); const shape = shapeOf(schema); if (!shape) throw new Error(`metadata type '${type}' is not an object schema (no walkable shape)`);