Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/govern-webhook-liveness.md
Original file line number Diff line number Diff line change
@@ -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).
51 changes: 51 additions & 0 deletions packages/cli/src/utils/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/utils/lint-liveness-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
];

/**
Expand Down
14 changes: 13 additions & 1 deletion packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
78 changes: 78 additions & 0 deletions packages/spec/liveness/webhook.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
20 changes: 18 additions & 2 deletions packages/spec/scripts/liveness/check-liveness.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown> = {
webhook: WebhookSchema,
};

// ADR-0010 provenance/lock overlay fields — system-stamped, on every type; auto-live.
const FRAMEWORK_FIELDS = new Set([
Expand Down Expand Up @@ -126,7 +142,7 @@ function childShape(s: any): Record<string, any> | 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)`);
Expand Down