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
23 changes: 23 additions & 0 deletions .changeset/webhook-liveness-ledger-flip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
---

chore(spec): flip webhook liveness ledger dead→live + webhook-materialization ADR-0054 proof (#3490)

Follow-up to #3489 (the materializer bridge). Now that stack-authored webhooks
are materialized into dispatchable `sys_webhook` rows, the props the materializer
+ dispatcher actually consume flip from `dead` to `live` in
`packages/spec/liveness/webhook.json` (`object`/`isActive`/`url`/`triggers`/
`method`/`name`/`headers`/`secret`/`timeoutMs` + display-only `label`/
`description`); `body`/`payloadFields`/`includeSession`/`retryPolicy`/`tags` stay
`dead` (folded into `definition_json` but never read — the #1878 delivery-layer
worklist) and `authentication` stays `experimental`. The `url` authorWarn is
dropped (authoring is live now).

Adds a `webhook-materialization` ADR-0054 high-risk proof class (bound to
`webhook.object`) with a `@objectstack/dogfood` proof that boots the real stack
WITHOUT realtime and asserts the row materializes — pinning the #3461 integration
seam (the bridge was first gated behind the realtime dispatch guard).

Liveness-ledger + gate assets (`packages/spec/liveness` + `scripts/liveness`, not
in the published spec runtime) and the private `@objectstack/dogfood` package
only — no package release.
42 changes: 10 additions & 32 deletions packages/cli/src/utils/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,29 +159,15 @@ 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/isActive/description are 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.
// #3494 pruned the aspirational body/payloadFields/includeSession/
// authentication/retryPolicy/tags props outright.)
// ── webhook (#3461 bridge landed → #3490; #3494 prune) ────────────────────
// Two things closed the old "entire surface is dead" state: #3494 PRUNED the
// aspirational dead props (body/payloadFields/includeSession/retryPolicy/tags/
// authentication) from the schema, and the #3489 materializer bridge makes
// every REMAINING prop live (object/isActive/url/triggers/method/name/headers/
// secret/timeoutMs/label/description). So a webhook has no misleading dead
// surface left — authoring one is silent. Runs against the REAL webhook.json.

it('does not warn on an authored webhook — all remaining props are live (#3490)', () => {
const findings = lintLivenessProperties({
webhooks: [{
name: 'showcase_task_changed',
Expand All @@ -193,14 +179,6 @@ describe('lintLivenessProperties', () => {
description: 'Sends task lifecycle events to an external system.',
}],
});
expect(findings.length).toBe(1);
expect(findings[0].message).toContain('`url`');
});

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);
expect(findings).toEqual([]);
});
});
2 changes: 2 additions & 0 deletions packages/qa/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"@objectstack/plugin-audit": "workspace:*",
"@objectstack/plugin-auth": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@objectstack/plugin-webhooks": "workspace:*",
"@objectstack/service-messaging": "workspace:*",
"@objectstack/service-storage": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/verify": "workspace:*"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Webhook materialization fixture — the deterministic ADR-0054 proof for the
// `webhook-materialization` high-risk class.
//
// A webhook prop is `live` in the ledger because a stack-authored `webhooks:`
// entry is materialized into a dispatchable `sys_webhook` row on boot
// (#3461/#3489) — but "materialized" is not "reads the schema". The authored
// value crosses manifest-decomposition → the ObjectQL registry (type `webhook`)
// → `bootstrapDeclaredWebhooks` → `engine.insert('sys_webhook')`, and the break
// can live in any seam: the bridge was first gated BEHIND the realtime/messaging
// dispatch guard, so a boot without realtime silently materialized nothing (the
// exact silent no-op #3461 set out to kill). This fixture authors ONE webhook
// against ONE object, with ZERO dependence on an example app, so the proof can
// assert the runtime outcome: the sys_webhook row exists with the spec→runtime
// remap applied (`object`→`object_name`, `isActive`→`active`).

import { defineStack } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';
import { defineWebhook } from '@objectstack/spec/automation';

/** The one object the authored webhook subscribes to. */
export const WmTask = ObjectSchema.create({
name: 'wm_task',
// [ADR-0090 D1] grandfather stamp: this fixture's gate under test is webhook
// materialization, not owner-sharing — keep RLS out of the way.
sharingModel: 'public_read_write',
label: 'WM Task',
pluralLabel: 'WM Tasks',
fields: {
name: Field.text({ label: 'Name', required: true }),
},
});

/**
* The stack-authored webhook. `object` (spec) must land as `object_name`
* (runtime col) and `isActive` as `active` — the remap the proof asserts.
*/
export const wmTaskChanged = defineWebhook({
name: 'wm_task_changed',
label: 'WM Task Changed',
object: 'wm_task',
triggers: ['create', 'update'],
url: 'https://hooks.example/wm-task',
isActive: true,
});

export const webhookFixtureStack = defineStack({
manifest: {
id: 'com.dogfood.webhook_fixture',
namespace: 'wm',
version: '0.0.0',
type: 'app',
name: 'Webhook Materialization Fixture',
description: 'Single-object app that authors one webhook to prove stack `webhooks:` entries materialize into dispatchable sys_webhook rows (ADR-0054, #3461).',
},
objects: [WmTask],
webhooks: [wmTaskChanged],
});
70 changes: 70 additions & 0 deletions packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// WEBHOOK MATERIALIZATION proof (ADR-0054), exercised end-to-end through the
// real in-process stack.
//
// @proof: webhook-materialization
// ADR-0054 runtime proof for the webhook-materialization high-risk class.
// Referenced by the liveness ledger entry `webhook.object`
// (packages/spec/liveness/webhook.json); the spec liveness gate fails if this
// tag is removed. See proof-registry.mts.
//
// A webhook prop being `live` means the materializer + dispatcher READ it —
// necessary but not sufficient. This boots the real stack with the webhook +
// messaging plugins, authors a webhook via the app config's `webhooks:` array,
// and asserts the observable runtime outcome: a dispatchable `sys_webhook` row
// materialized on boot with the spec→runtime remap applied (`object`→
// `object_name`, `isActive`→`active`). It also pins the #3461 integration bug:
// the fixture boots WITHOUT realtime, so a materializer re-gated behind the
// dispatch guard would materialize nothing and this proof would fail.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import { MessagingServicePlugin } from '@objectstack/service-messaging';
import { WebhookOutboxPlugin } from '@objectstack/plugin-webhooks';
import { webhookFixtureStack } from './fixtures/webhook-materialization-fixture.js';

const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };

describe('objectstack verify WEBHOOK: authored webhook materializes into sys_webhook (#webhook-materialization)', () => {
let stack: VerifyStack;

beforeAll(async () => {
// `extraPlugins` mirrors the capability plugins `objectstack serve` mounts
// for `requires: ['webhooks']` — messaging first (WebhookOutboxPlugin
// depends on it), then the webhook plugin. Deliberately NO realtime service:
// materialization must run on the data engine alone (bootDeclaredWebhooks),
// independent of the auto-enqueue dispatch prerequisites.
stack = await bootStack(webhookFixtureStack, {
extraPlugins: [new MessagingServicePlugin(), new WebhookOutboxPlugin()],
});
}, 60_000);

afterAll(async () => {
await stack?.stop();
});

it('materializes the stack-authored webhook into a sys_webhook row (object→object_name, isActive→active)', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const engine = (await stack.kernel.getServiceAsync('objectql')) as any;
const rows = await engine.find('sys_webhook', {
filter: { name: 'wm_task_changed' },
context: SYSTEM_CTX,
});

expect(rows, 'authored webhook was NOT materialized into a sys_webhook row').toHaveLength(1);
const row = rows[0];

// The remaps that are the whole point of the bridge.
expect(row.object_name, 'spec `object` did not remap to runtime `object_name`').toBe('wm_task');
expect(row.active, 'spec `isActive:true` did not remap to runtime `active`').toBeTruthy();

// Boot-seeded provenance (seed-not-clobber): a package row, not admin.
expect(row.managed_by).toBe('package');

// The full envelope is stashed for the dispatcher's advanced-config read.
const defn = JSON.parse(row.definition_json);
expect(defn.object).toBe('wm_task');
expect(defn.triggers).toEqual(['create', 'update']);
});
});
Loading