Skip to content

Commit b98716d

Browse files
os-zhuangclaude
andcommitted
chore(spec,cli): enroll webhook in the liveness GOVERNED set (#3462)
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 <noreply@anthropic.com>
1 parent 7aea626 commit b98716d

6 files changed

Lines changed: 197 additions & 3 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
chore(spec,cli): enroll `webhook` in the liveness GOVERNED set (#3462)
7+
8+
Closes the final third of #3462 (umbrella #1878) — `report` and `dashboard`
9+
landed in #3474; `webhook` was deferred for two reasons, both handled here.
10+
11+
- **Not a registered metadata type.** `webhook` is absent from the metadata-type
12+
registry, so the gate can't resolve it via `getMetadataTypeSchema`. Registering
13+
it would switch on Studio webhook CRUD, `saveMetaItem` overlay acceptance, and
14+
diagnostics sweeping — the wrong move while the authoring surface is still
15+
disconnected (below). Instead the gate resolves it through a small
16+
`SPEC_ONLY_SCHEMAS` override in `check-liveness.mts` (consulted before the
17+
registry): the gate only needs to **walk** the schema, not register it.
18+
- **The whole authoring surface is dead (#3461).** Nothing materializes an
19+
authored `webhooks:` entry (stack/connector) into a `sys_webhook` dispatcher
20+
row — the runtime reads only admin-authored `sys_webhook` rows. So
21+
`packages/spec/liveness/webhook.json` classifies all 16 authorable props
22+
**dead** and `authentication` **experimental** (HMAC-`secret`-only, its
23+
existing marker). Per-prop notes record which props a future materializer
24+
(#3461 option A) could remap (e.g. `object``object_name`, `isActive``active`)
25+
vs which have no sink anywhere — doubling as that mapping table.
26+
- **Author-warning wired (`@objectstack/cli`).** Added
27+
`{ type: 'webhook', key: 'webhooks' }` to `TYPE_COLLECTIONS` in
28+
`lint-liveness-properties.ts`, so `os compile` now advises authors that
29+
`webhooks:` is a silent no-op. The required `url` prop carries the single
30+
warning per webhook (one heads-up per artifact, not one per dead prop);
31+
`isActive` is left unmarked (default(true) boolean).
32+
33+
This is enrollment only — it does **not** decide #3461's build-the-bridge vs
34+
retire-the-surface question. When that lands, the mapped props flip to live (cite
35+
the materializer) or the ledger is removed with the schema. No spec shape/behavior
36+
change (ledger + gate/lint config only).

packages/cli/src/utils/lint-liveness-properties.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,55 @@ describe('lintLivenessProperties', () => {
158158
});
159159
expect(findings).toEqual([]);
160160
});
161+
162+
// ── webhook (#3462) ───────────────────────────────────────────────────────
163+
// The ENTIRE WebhookSchema authoring surface is disconnected from the
164+
// sys_webhook dispatcher (#3461): authoring `webhooks:` never materializes a
165+
// dispatchable row. Rather than warn on all 16 dead props, the ledger warns
166+
// once per webhook via the required `url` carrier — one no-op heads-up per
167+
// artifact. These run against the REAL webhook.json ledger.
168+
169+
it('warns once per authored webhook via the url carrier', () => {
170+
const findings = lintLivenessProperties({
171+
webhooks: [{ name: 'w1', url: 'https://hooks.example/x' }],
172+
});
173+
const f = findings.find((x) => x.message.includes('`url`'));
174+
expect(f).toBeDefined();
175+
expect(f!.where).toBe("webhook 'w1'");
176+
expect(f!.hint.toLowerCase()).toContain('sys_webhook');
177+
});
178+
179+
it('emits exactly one warning for a fully-authored (showcase-shaped) webhook', () => {
180+
// object/triggers/method/retryPolicy/isActive/description are all dead too,
181+
// but only `url` carries authorWarn — so the whole no-op artifact yields ONE
182+
// finding, not one-per-prop. (isActive is default(true), deliberately unmarked.)
183+
const findings = lintLivenessProperties({
184+
webhooks: [{
185+
name: 'showcase_task_changed',
186+
object: 'showcase_task',
187+
triggers: ['create', 'update', 'delete'],
188+
url: 'https://hooks.example/showcase/task',
189+
method: 'POST',
190+
retryPolicy: { maxRetries: 3, backoffStrategy: 'exponential' },
191+
isActive: true,
192+
description: 'Sends task lifecycle events to an external system.',
193+
}],
194+
});
195+
expect(findings.length).toBe(1);
196+
expect(findings[0].message).toContain('`url`');
197+
});
198+
199+
it('also warns on authentication (experimental — HMAC-secret-only)', () => {
200+
const findings = lintLivenessProperties({
201+
webhooks: [{ name: 'w1', url: 'https://hooks.example/x', authentication: { type: 'bearer' } }],
202+
});
203+
expect(paths(findings).some((m) => m.includes('`authentication`'))).toBe(true);
204+
});
205+
206+
it('does NOT warn on isActive (default(true) boolean, deliberately unmarked)', () => {
207+
const findings = lintLivenessProperties({
208+
webhooks: [{ name: 'w1', url: 'https://hooks.example/x', isActive: true }],
209+
});
210+
expect(paths(findings).some((m) => m.includes('`isActive`'))).toBe(false);
211+
});
161212
});

packages/cli/src/utils/lint-liveness-properties.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
181181
{ type: 'hook', key: 'hooks' },
182182
{ type: 'page', key: 'pages' },
183183
{ type: 'view', key: 'views' },
184+
{ type: 'webhook', key: 'webhooks' },
184185
];
185186

186187
/**

packages/spec/liveness/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ This matters: the older gate read the generated `json-schema/` directory, which
2323
most top-level authorable types (object/field/flow/action/...) — so it was blind to the
2424
core surface. The registry is complete.
2525

26+
**Spec-only exception (`SPEC_ONLY_SCHEMAS`).** A type can be authorable yet deliberately
27+
*not* registered — `webhook` is the case: its schema is authored on a Stack/connector but
28+
registering it as a metadata type would switch on Studio webhook CRUD, `saveMetaItem`
29+
overlay acceptance, and diagnostics sweeping, which is wrong while the surface is still
30+
disconnected from the `sys_webhook` dispatcher (#3461). Since being off the registry is
31+
*itself* how such a drift hides, the gate resolves these through a small
32+
`SPEC_ONLY_SCHEMAS` override in `check-liveness.mts` (consulted before
33+
`getMetadataTypeSchema`) — it only needs to **walk** the schema, not register it. When a
34+
disconnect like `webhook`'s is resolved (materializer built or surface retired), fold the
35+
type back onto the registry and drop the override.
36+
2637
## Status vocabulary
2738

2839
| Status | Meaning |
@@ -166,7 +177,7 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type
166177
RecordDetailView had been gating the History tab on it the whole time (#2707).
167178
4. Add the type to `GOVERNED`; confirm the gate is green.
168179

169-
## Current state — 13 governed types
180+
## Current state — 16 governed types
170181

171182
Counts include drilled `children` entries; regenerate with the snippet below rather
172183
than hand-editing (this table drifted badly once — field was listed 34/39 while the
@@ -205,6 +216,7 @@ EOF
205216

206217
| 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) |
207218
| 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) |
219+
| 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 |
208220

209221
The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every
210222
misleading entry carries `authorWarn` so authors hear about it at compile time.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
{
2+
"type": "webhook",
3+
"_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).",
4+
"props": {
5+
"name": {
6+
"status": "dead",
7+
"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."
8+
},
9+
"label": {
10+
"status": "dead",
11+
"note": "Authored value never materialized. Runtime col sys-webhook.object.ts:105 (Studio list display only; never sent on the wire). Salvageable 1:1."
12+
},
13+
"object": {
14+
"status": "dead",
15+
"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."
16+
},
17+
"triggers": {
18+
"status": "dead",
19+
"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."
20+
},
21+
"url": {
22+
"status": "dead",
23+
"authorWarn": true,
24+
"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:`.",
25+
"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."
26+
},
27+
"method": {
28+
"status": "dead",
29+
"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."
30+
},
31+
"headers": {
32+
"status": "dead",
33+
"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."
34+
},
35+
"body": {
36+
"status": "dead",
37+
"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."
38+
},
39+
"payloadFields": {
40+
"status": "dead",
41+
"note": "No sink anywhere. No field projection — the full record is always sent (auto-enqueuer.ts:341). Never read."
42+
},
43+
"includeSession": {
44+
"status": "dead",
45+
"note": "No sink anywhere. No session is injected into the enqueue payload. Never read."
46+
},
47+
"authentication": {
48+
"status": "experimental",
49+
"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).",
50+
"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."
51+
},
52+
"retryPolicy": {
53+
"status": "dead",
54+
"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.)"
55+
},
56+
"timeoutMs": {
57+
"status": "dead",
58+
"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."
59+
},
60+
"secret": {
61+
"status": "dead",
62+
"note": "Authored value never materialized. Honored only from `definition_json` (auto-enqueuer.ts:276 → HMAC signingSecret at :334), never from this top-level prop."
63+
},
64+
"isActive": {
65+
"status": "dead",
66+
"_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.",
67+
"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."
68+
},
69+
"description": {
70+
"status": "dead",
71+
"note": "Authored value never materialized. Runtime col sys-webhook.object.ts:160 (Studio-editable; inert in dispatch). Salvageable 1:1."
72+
},
73+
"tags": {
74+
"status": "dead",
75+
"note": "No sink anywhere. No sys_webhook column, never read. Vestigial."
76+
}
77+
}
78+
}

packages/spec/scripts/liveness/check-liveness.mts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs';
3838
import { fileURLToPath } from 'node:url';
3939
import { dirname, join, resolve } from 'node:path';
4040
import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '../../src/kernel/metadata-type-schemas';
41+
import { WebhookSchema } from '../../src/automation/webhook.zod';
4142
import {
4243
BOUND_PROOF_PATHS,
4344
HIGH_RISK_CLASSES,
@@ -53,7 +54,22 @@ const repoRoot = resolve(specRoot, '../..');
5354
const ledgerRoot = join(specRoot, 'liveness');
5455

5556
// Governed metadata types, rolled out highest-frequency / highest-risk first.
56-
const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard'];
57+
const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook'];
58+
59+
// Spec-only override: governed types whose canonical schema is NOT (yet) in the
60+
// metadata-type registry, so they can't be resolved via getMetadataTypeSchema.
61+
// The ledger still governs them — being off the registry is exactly why a drift
62+
// can survive: neither the runtime /meta/types endpoint nor Studio's admin forms
63+
// touch these, so nothing else notices when the spec surface goes stale.
64+
//
65+
// `webhook` is deliberately NOT registered: registering it would turn on Studio
66+
// webhook CRUD + saveMetaItem overlay acceptance + diagnostics sweeping, which is
67+
// the wrong move while the WebhookSchema authoring surface is still disconnected
68+
// from the sys_webhook dispatcher (enforce-or-remove pending, #3461). The gate
69+
// only needs to WALK the schema, not register it — so we resolve it directly.
70+
const SPEC_ONLY_SCHEMAS: Record<string, unknown> = {
71+
webhook: WebhookSchema,
72+
};
5773

5874
// ADR-0010 provenance/lock overlay fields — system-stamped, on every type; auto-live.
5975
const FRAMEWORK_FIELDS = new Set([
@@ -126,7 +142,7 @@ function childShape(s: any): Record<string, any> | null {
126142
}
127143

128144
function topProps(type: string): Array<{ key: string; node: any; description: string }> {
129-
const schema = getMetadataTypeSchema(type);
145+
const schema = SPEC_ONLY_SCHEMAS[type] ?? getMetadataTypeSchema(type);
130146
if (!schema) throw new Error(`metadata type '${type}' has no registered schema`);
131147
const shape = shapeOf(schema);
132148
if (!shape) throw new Error(`metadata type '${type}' is not an object schema (no walkable shape)`);

0 commit comments

Comments
 (0)