Skip to content

Commit b44d3ef

Browse files
os-zhuangclaude
andcommitted
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>
1 parent 8f124a7 commit b44d3ef

8 files changed

Lines changed: 222 additions & 36 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/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)