Skip to content

Commit 4f1f843

Browse files
os-zhuangclaude
andauthored
chore(spec): flip webhook liveness ledger dead→live + webhook-materialization proof (#3490) (#3518)
* chore(spec): flip webhook liveness ledger dead→live + webhook-materialization proof (#3490) 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 <noreply@anthropic.com> * test(spec): sync proof-registry test to the webhook-materialization bound class (#3490) 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 <noreply@anthropic.com> * fix(spec,cli): redirect webhook authorWarn from now-live url to still-dead props (#3490) 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). --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d729a31 commit 4f1f843

10 files changed

Lines changed: 228 additions & 62 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
---
3+
4+
chore(spec): flip webhook liveness ledger dead→live + webhook-materialization ADR-0054 proof (#3490)
5+
6+
Follow-up to #3489 (the materializer bridge). Now that stack-authored webhooks
7+
are materialized into dispatchable `sys_webhook` rows, the props the materializer
8+
+ dispatcher actually consume flip from `dead` to `live` in
9+
`packages/spec/liveness/webhook.json` (`object`/`isActive`/`url`/`triggers`/
10+
`method`/`name`/`headers`/`secret`/`timeoutMs` + display-only `label`/
11+
`description`); `body`/`payloadFields`/`includeSession`/`retryPolicy`/`tags` stay
12+
`dead` (folded into `definition_json` but never read — the #1878 delivery-layer
13+
worklist) and `authentication` stays `experimental`. The `url` authorWarn is
14+
dropped (authoring is live now).
15+
16+
Adds a `webhook-materialization` ADR-0054 high-risk proof class (bound to
17+
`webhook.object`) with a `@objectstack/dogfood` proof that boots the real stack
18+
WITHOUT realtime and asserts the row materializes — pinning the #3461 integration
19+
seam (the bridge was first gated behind the realtime dispatch guard).
20+
21+
Liveness-ledger + gate assets (`packages/spec/liveness` + `scripts/liveness`, not
22+
in the published spec runtime) and the private `@objectstack/dogfood` package
23+
only — no package release.

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

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -159,29 +159,15 @@ describe('lintLivenessProperties', () => {
159159
expect(findings).toEqual([]);
160160
});
161161

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/isActive/description are dead too, but only `url`
181-
// carries authorWarn — so the whole no-op artifact yields ONE finding, not
182-
// one-per-prop. (isActive is default(true), deliberately unmarked.
183-
// #3494 pruned the aspirational body/payloadFields/includeSession/
184-
// authentication/retryPolicy/tags props outright.)
162+
// ── webhook (#3461 bridge landed → #3490; #3494 prune) ────────────────────
163+
// Two things closed the old "entire surface is dead" state: #3494 PRUNED the
164+
// aspirational dead props (body/payloadFields/includeSession/retryPolicy/tags/
165+
// authentication) from the schema, and the #3489 materializer bridge makes
166+
// every REMAINING prop live (object/isActive/url/triggers/method/name/headers/
167+
// secret/timeoutMs/label/description). So a webhook has no misleading dead
168+
// surface left — authoring one is silent. Runs against the REAL webhook.json.
169+
170+
it('does not warn on an authored webhook — all remaining props are live (#3490)', () => {
185171
const findings = lintLivenessProperties({
186172
webhooks: [{
187173
name: 'showcase_task_changed',
@@ -193,14 +179,6 @@ describe('lintLivenessProperties', () => {
193179
description: 'Sends task lifecycle events to an external system.',
194180
}],
195181
});
196-
expect(findings.length).toBe(1);
197-
expect(findings[0].message).toContain('`url`');
198-
});
199-
200-
it('does NOT warn on isActive (default(true) boolean, deliberately unmarked)', () => {
201-
const findings = lintLivenessProperties({
202-
webhooks: [{ name: 'w1', url: 'https://hooks.example/x', isActive: true }],
203-
});
204-
expect(paths(findings).some((m) => m.includes('`isActive`'))).toBe(false);
182+
expect(findings).toEqual([]);
205183
});
206184
});

packages/qa/dogfood/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"@objectstack/plugin-audit": "workspace:*",
2020
"@objectstack/plugin-auth": "workspace:*",
2121
"@objectstack/plugin-security": "workspace:*",
22+
"@objectstack/plugin-webhooks": "workspace:*",
23+
"@objectstack/service-messaging": "workspace:*",
2224
"@objectstack/service-storage": "workspace:*",
2325
"@objectstack/spec": "workspace:*",
2426
"@objectstack/verify": "workspace:*"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Webhook materialization fixture — the deterministic ADR-0054 proof for the
4+
// `webhook-materialization` high-risk class.
5+
//
6+
// A webhook prop is `live` in the ledger because a stack-authored `webhooks:`
7+
// entry is materialized into a dispatchable `sys_webhook` row on boot
8+
// (#3461/#3489) — but "materialized" is not "reads the schema". The authored
9+
// value crosses manifest-decomposition → the ObjectQL registry (type `webhook`)
10+
// → `bootstrapDeclaredWebhooks` → `engine.insert('sys_webhook')`, and the break
11+
// can live in any seam: the bridge was first gated BEHIND the realtime/messaging
12+
// dispatch guard, so a boot without realtime silently materialized nothing (the
13+
// exact silent no-op #3461 set out to kill). This fixture authors ONE webhook
14+
// against ONE object, with ZERO dependence on an example app, so the proof can
15+
// assert the runtime outcome: the sys_webhook row exists with the spec→runtime
16+
// remap applied (`object`→`object_name`, `isActive`→`active`).
17+
18+
import { defineStack } from '@objectstack/spec';
19+
import { ObjectSchema, Field } from '@objectstack/spec/data';
20+
import { defineWebhook } from '@objectstack/spec/automation';
21+
22+
/** The one object the authored webhook subscribes to. */
23+
export const WmTask = ObjectSchema.create({
24+
name: 'wm_task',
25+
// [ADR-0090 D1] grandfather stamp: this fixture's gate under test is webhook
26+
// materialization, not owner-sharing — keep RLS out of the way.
27+
sharingModel: 'public_read_write',
28+
label: 'WM Task',
29+
pluralLabel: 'WM Tasks',
30+
fields: {
31+
name: Field.text({ label: 'Name', required: true }),
32+
},
33+
});
34+
35+
/**
36+
* The stack-authored webhook. `object` (spec) must land as `object_name`
37+
* (runtime col) and `isActive` as `active` — the remap the proof asserts.
38+
*/
39+
export const wmTaskChanged = defineWebhook({
40+
name: 'wm_task_changed',
41+
label: 'WM Task Changed',
42+
object: 'wm_task',
43+
triggers: ['create', 'update'],
44+
url: 'https://hooks.example/wm-task',
45+
isActive: true,
46+
});
47+
48+
export const webhookFixtureStack = defineStack({
49+
manifest: {
50+
id: 'com.dogfood.webhook_fixture',
51+
namespace: 'wm',
52+
version: '0.0.0',
53+
type: 'app',
54+
name: 'Webhook Materialization Fixture',
55+
description: 'Single-object app that authors one webhook to prove stack `webhooks:` entries materialize into dispatchable sys_webhook rows (ADR-0054, #3461).',
56+
},
57+
objects: [WmTask],
58+
webhooks: [wmTaskChanged],
59+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// WEBHOOK MATERIALIZATION proof (ADR-0054), exercised end-to-end through the
4+
// real in-process stack.
5+
//
6+
// @proof: webhook-materialization
7+
// ADR-0054 runtime proof for the webhook-materialization high-risk class.
8+
// Referenced by the liveness ledger entry `webhook.object`
9+
// (packages/spec/liveness/webhook.json); the spec liveness gate fails if this
10+
// tag is removed. See proof-registry.mts.
11+
//
12+
// A webhook prop being `live` means the materializer + dispatcher READ it —
13+
// necessary but not sufficient. This boots the real stack with the webhook +
14+
// messaging plugins, authors a webhook via the app config's `webhooks:` array,
15+
// and asserts the observable runtime outcome: a dispatchable `sys_webhook` row
16+
// materialized on boot with the spec→runtime remap applied (`object`→
17+
// `object_name`, `isActive`→`active`). It also pins the #3461 integration bug:
18+
// the fixture boots WITHOUT realtime, so a materializer re-gated behind the
19+
// dispatch guard would materialize nothing and this proof would fail.
20+
21+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22+
import { bootStack, type VerifyStack } from '@objectstack/verify';
23+
import { MessagingServicePlugin } from '@objectstack/service-messaging';
24+
import { WebhookOutboxPlugin } from '@objectstack/plugin-webhooks';
25+
import { webhookFixtureStack } from './fixtures/webhook-materialization-fixture.js';
26+
27+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
28+
29+
describe('objectstack verify WEBHOOK: authored webhook materializes into sys_webhook (#webhook-materialization)', () => {
30+
let stack: VerifyStack;
31+
32+
beforeAll(async () => {
33+
// `extraPlugins` mirrors the capability plugins `objectstack serve` mounts
34+
// for `requires: ['webhooks']` — messaging first (WebhookOutboxPlugin
35+
// depends on it), then the webhook plugin. Deliberately NO realtime service:
36+
// materialization must run on the data engine alone (bootDeclaredWebhooks),
37+
// independent of the auto-enqueue dispatch prerequisites.
38+
stack = await bootStack(webhookFixtureStack, {
39+
extraPlugins: [new MessagingServicePlugin(), new WebhookOutboxPlugin()],
40+
});
41+
}, 60_000);
42+
43+
afterAll(async () => {
44+
await stack?.stop();
45+
});
46+
47+
it('materializes the stack-authored webhook into a sys_webhook row (object→object_name, isActive→active)', async () => {
48+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
49+
const engine = (await stack.kernel.getServiceAsync('objectql')) as any;
50+
const rows = await engine.find('sys_webhook', {
51+
filter: { name: 'wm_task_changed' },
52+
context: SYSTEM_CTX,
53+
});
54+
55+
expect(rows, 'authored webhook was NOT materialized into a sys_webhook row').toHaveLength(1);
56+
const row = rows[0];
57+
58+
// The remaps that are the whole point of the bridge.
59+
expect(row.object_name, 'spec `object` did not remap to runtime `object_name`').toBe('wm_task');
60+
expect(row.active, 'spec `isActive:true` did not remap to runtime `active`').toBeTruthy();
61+
62+
// Boot-seeded provenance (seed-not-clobber): a package row, not admin.
63+
expect(row.managed_by).toBe('package');
64+
65+
// The full envelope is stashed for the dispatcher's advanced-config read.
66+
const defn = JSON.parse(row.definition_json);
67+
expect(defn.object).toBe('wm_task');
68+
expect(defn.triggers).toEqual(['create', 'update']);
69+
});
70+
});

0 commit comments

Comments
 (0)