Skip to content
Merged
18 changes: 18 additions & 0 deletions .changeset/24997-webhook-capability-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': minor
---

feat(spec)!: `defineStack` refuses a stack that declares a webhook while `requires` lacks `'webhooks'` or `'realtime'`, with `STACK_WEBHOOK_CAPABILITY_REQUIRED`

A declared webhook is dispatched by two capabilities together: `'webhooks'` mounts `@objectstack/plugin-webhooks`, whose auto-enqueuer turns a record event into a delivery, and `'realtime'` mounts `@objectstack/service-realtime`, that auto-enqueuer's only source of record events. Neither token implies the other, and a scaffolded app's `requires` is `['automation', 'triggers']`, which carries neither. So a stack that declared a webhook without both validated, built and booted; the activation door answered `enabled: true`; every record write answered `200`; and no delivery was ever enqueued. The only trace was one boot `error`, after deploy. `defineStack` now refuses such a stack at load, the third declared-capability refusal beside `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` and `STACK_TRIGGER_CAPABILITY_REQUIRED`, so every door that loads it (`os validate`, `os build`, `os serve` / `os dev`, an app's own `bootStack` tests) stops with the same sentence.

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which `defineStack` input is accepted: a stack that declares a webhook and lacks one of two capability tokens is refused at load. The remedy is a capability declaration the author makes in their own `requires`, never a rewrite of a key or a stored row, and no token is implied or added for them, deliberately (ruling A on #24910). The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**BREAKING** (the `defineStack` accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.

- **FROM:** a stack with a non-empty top-level `webhooks:` collection and a `requires` missing `'webhooks'`, `'realtime'` or both (an absent `requires` included) was accepted, and its webhooks never delivered.
- **TO:** `defineStack` throws `STACK_WEBHOOK_CAPABILITY_REQUIRED` (`status: 422`), header `defineStack webhook capability validation failed (N issues):`, with one `issues` entry per declared webhook naming the webhook and the missing token or tokens. One code for every arm: which token is missing is in the finding, not in the code.
- **Unchanged:** a stack with no declared webhook owes neither token; `defineStack(config, { strict: false })` skips the judgement by contract; the activation door answers as before; and the boot `error` that `@objectstack/plugin-webhooks` logs for a host that wires a stack by hand, past `defineStack`, stays. Every declared webhook counts, `isActive: false` included, because the activation door can switch it on.
- **The fix for an author:** add both tokens to the stack's `requires`, beside whatever it already declares, for example `requires: ['automation', 'triggers', 'webhooks', 'realtime']`. The refusal's own line names exactly the tokens to add.
22 changes: 16 additions & 6 deletions content/docs/automation/webhooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,13 @@ A webhook reaches the dispatcher one of two ways:
own `active` field.

**The capabilities a declared webhook needs.** A stack that declares a webhook
must name two tokens in `requires`, beside whatever else it declares:
must name two tokens in `requires`, `'webhooks'` and `'realtime'`, beside
whatever else it declares. A stack that declares one while `requires` lacks
either token is **refused**: `defineStack` throws
`STACK_WEBHOOK_CAPABILITY_REQUIRED` (status 422), naming each declared webhook
and the missing token or tokens, so `os validate`, `os build`, `os serve` /
`os dev` and an app's own `bootStack` tests all stop at load with the same
sentence. Neither token is added for you:

```ts
requires: ['automation', 'triggers', 'webhooks', 'realtime'],
Expand All @@ -113,14 +119,18 @@ requires: ['automation', 'triggers', 'webhooks', 'realtime'],
- `'realtime'` mounts the realtime service, which is the auto-enqueuer's only
source of record events: the engine publishes `data.record.*` through it, and
nothing else feeds the enqueuer (§4.1). With `'webhooks'` and without
`'realtime'` the declaration is read but never dispatched. Record writes still
succeed and no delivery is ever enqueued. The boot logs one `error` that names
each declared webhook and the missing token. Switching a declared webhook on
through the activation door delivers nothing on such a deployment either.
`'realtime'` the declaration would be read but never dispatched: record
writes would succeed and no delivery would ever be enqueued. That is why
`defineStack` refuses the stack. A host that wires a stack by hand, past
`defineStack`, still gets one boot `error` that names each declared webhook
and the missing token; switching a declared webhook on through the activation
door delivers nothing on such a deployment.

The outbox itself comes from `messaging`, which every boot mounts unless it runs
with `--preset minimal`. A scaffolded app's `requires` is
`['automation', 'triggers']`, so it carries neither of the two tokens above.
`['automation', 'triggers']`, so it carries neither of the two tokens above:
add both before you declare its first webhook. Every declared webhook counts,
`isActive: false` included, because the activation door can switch it on.

On a database an 18.0 pre-release (`18.0.0-next.*`) created, the rows that release's boot seeder
wrote for declared webhooks (`managed_by: 'package'`) are still there. Until the
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ const result = AutomationApiErrorCode.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +340 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +341 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |

### Allowed Values: `FlowInnerRefusal.code`
Expand Down Expand Up @@ -673,6 +673,7 @@ const result = AutomationApiErrorCode.parse(data);
* `STACK_SINGLE_APP_VIOLATION`
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
* `STACK_UNMANAGED_INSTALL_UNCOPYABLE`
* `STACK_WEBHOOK_CAPABILITY_REQUIRED`
* `STORED_TYPE_NOT_CANONICAL`
* `STORE_UNAVAILABLE`
* `SUGGESTION_CONFIRM_FAILED`
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +340 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +341 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
| **message** | `string` | ✅ | Readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
Expand Down Expand Up @@ -356,6 +356,7 @@ const result = ApiErrorSchema.parse(data);
* `STACK_SINGLE_APP_VIOLATION`
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
* `STACK_UNMANAGED_INSTALL_UNCOPYABLE`
* `STACK_WEBHOOK_CAPABILITY_REQUIRED`
* `STORED_TYPE_NOT_CANONICAL`
* `STORE_UNAVAILABLE`
* `SUGGESTION_CONFIRM_FAILED`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ const result = ErrorCode.parse(data);
* `STACK_SINGLE_APP_VIOLATION`
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
* `STACK_UNMANAGED_INSTALL_UNCOPYABLE`
* `STACK_WEBHOOK_CAPABILITY_REQUIRED`
* `STORED_TYPE_NOT_CANONICAL`
* `STORE_UNAVAILABLE`
* `SUGGESTION_CONFIRM_FAILED`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ export const wmTaskChanged = defineWebhook({
isActive: true,
});

/**
* Built with `strict: false`, on purpose — DECLARED, not an oversight. This
* stack declares a webhook and no `requires`, so `defineStack`'s default
* judgement refuses it (`STACK_WEBHOOK_CAPABILITY_REQUIRED`: a declared webhook
* owes `requires: ['webhooks', 'realtime']`, ruling A on #24910), and every
* authoring door would stop it at load. What this fixture models is the one
* composition that judgement cannot reach: a host that wires the stack by hand,
* with the webhook plugin and NO realtime service, which is exactly what the
* proof needs — the declared-webhook reader must bind on the data engine alone.
* Declaring the pair instead would have `bootStack` mount realtime the way
* `serve` does, and a reader re-gated behind the dispatch prerequisites would
* then pass unseen. Everything else about the stack is an ordinary authored
* package.
*/
export const webhookFixtureStack = defineStack({
manifest: {
id: 'com.dogfood.webhook-fixture',
Expand All @@ -57,4 +71,4 @@ export const webhookFixtureStack = defineStack({
},
objects: [WmTask],
webhooks: [wmTaskChanged],
});
}, { strict: false });
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ const WcrTask = ObjectSchema.create({
fields: { name: Field.text({ label: 'Name', required: true }) },
});

/**
* Built with `strict: false`, on purpose — DECLARED, not an oversight. This
* stack declares a webhook and no `requires`, so `defineStack`'s default
* judgement refuses it (`STACK_WEBHOOK_CAPABILITY_REQUIRED`: a declared webhook
* owes `requires: ['webhooks', 'realtime']`, ruling A on #24910). The pins
* below need a composition that judgement cannot reach: a hand-wired host
* WITHOUT `@objectstack/plugin-webhooks`, where the redactor must still hold
* because it is derived from the schema rather than registered by the plugin.
* Declaring the pair instead would have `bootStack` mount the webhook plugin
* the way `serve` does, and the "without plugin-webhooks" composition would
* silently stop existing. Everything else about the stack is an ordinary
* authored package.
*/
const fixture = defineStack({
manifest: {
id: 'com.dogfood.webhook-credential-redaction',
Expand All @@ -79,7 +92,7 @@ const fixture = defineStack({
isActive: false,
}),
],
});
}, { strict: false });

const savedBody = () => ({
name: SAVED,
Expand Down
7 changes: 7 additions & 0 deletions packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1554,6 +1554,13 @@ export const ERROR_CODE_LEDGER = {
// of another package's items. Its install-time counterpart is
// `UNMANAGED_INSTALL_UNCOPYABLE` under `@objectstack/metadata-protocol`.
'STACK_UNMANAGED_INSTALL_UNCOPYABLE',
// [ruling A on #24910] The third declared-capability refusal of the same
// family and reading (`door: 'none'`, `status: 422`), beside
// `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` and
// `STACK_TRIGGER_CAPABILITY_REQUIRED`: a declared webhook while `requires`
// omits `webhooks` or `realtime`, the pair that dispatches it. One code for
// every arm; which token is missing is in the finding.
'STACK_WEBHOOK_CAPABILITY_REQUIRED',
// [#16348] The COMPOSITION half of the same family, and `door: 'none'` on
// the same reading: the six `composeStacks` refusals, one code per raise
// site, every one `status: 422` (`StackRefusalError`, `stack.zod.ts`), the
Expand Down
15 changes: 13 additions & 2 deletions packages/spec/src/stack-conversions-record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,17 @@ describe('every refusal site in defineStack carries the record — census, both
code: 'STACK_TRIGGER_CAPABILITY_REQUIRED',
config: { manifest, objects: [task], pages, requires: ['automation'], flows: [recordFlow] },
},
{
site: 'webhook capability',
code: 'STACK_WEBHOOK_CAPABILITY_REQUIRED',
config: {
manifest,
objects: [task],
pages,
requires: ['webhooks'],
webhooks: [{ name: 'probe_hook', label: 'Hook', object: task.name, triggers: ['create'], url: 'https://hooks.example/probe' }],
},
},
{
site: 'unmanaged install mode',
code: 'STACK_UNMANAGED_INSTALL_UNCOPYABLE',
Expand All @@ -553,8 +564,8 @@ describe('every refusal site in defineStack carries the record — census, both
});
}

it('the census reaches every code defineStack refuses with: eight distinct codes', () => {
expect(new Set(rows.map((r) => r.code)).size).toBe(8);
it('the census reaches every code defineStack refuses with: nine distinct codes', () => {
expect(new Set(rows.map((r) => r.code)).size).toBe(9);
});
});

Expand Down
3 changes: 2 additions & 1 deletion packages/spec/src/stack-duplicate-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ const BATCH: readonly Row[] = [
byName('permissions', 'Permission set', { name: 'dupe_perm', label: 'Perm', objects: {} }),
byName('capabilities', 'Capability', { name: 'dupe.export', label: 'Export' }),
byName('sharingRules', 'Sharing rule', { name: 'dupe_share', label: 'Share', object: 'dupe_thing', type: 'criteria', condition: 'true', sharedWith: { type: 'team', value: 'team_ops' } }),
byName('webhooks', 'Webhook', { name: 'dupe_webhook', label: 'Webhook', object: 'dupe_thing', triggers: ['create'], url: 'https://example.com/hook' }),
// A declared webhook owes the pair that dispatches it (ruling A on #24910).
byName('webhooks', 'Webhook', { name: 'dupe_webhook', label: 'Webhook', object: 'dupe_thing', triggers: ['create'], url: 'https://example.com/hook' }, { requires: ['webhooks', 'realtime'] }),
byName('hooks', 'Hook', { name: 'dupe_hook', label: 'Hook', object: 'dupe_thing', events: ['beforeInsert'], handler: () => {} }),
byName('mappings', 'Mapping', { name: 'dupe_map', label: 'Map', targetObject: 'dupe_thing', fieldMapping: [{ source: 'a', target: 'title' }] }),
byName('analyticsCubes', 'Analytics cube', { name: 'dupe_cube', title: 'Cube', description: 'first', sql: 'dupe_thing', measures: {}, dimensions: {} }),
Expand Down
26 changes: 22 additions & 4 deletions packages/spec/src/stack-refusal-envelopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* ## What was wrong
*
* `defineStack` had seven refusal sites (ADR-0131 D6 adds the eighth, the
* `unmanaged` install mode's). After #14552 one of them — the
* `unmanaged` install mode's; ruling A on #24910 the ninth, the declared
* webhook's). After #14552 one of them — the
* cross-reference refusal, pinned in `stack-cross-reference-envelope.test.ts`
* — carried `code` / `status`; the other six threw `new Error(message)` with
* both `undefined`. A consumer that had learned to branch on `error.code`
Expand Down Expand Up @@ -86,6 +87,15 @@ const recordFlow = {
edges: [{ id: 'e1', source: 'start', target: 'end' }],
};

/** A declared webhook — dispatched only by `requires: ['webhooks', 'realtime']`, the pair. */
const taskWebhook = {
name: 'probe_task_changed',
label: 'Task changed',
object: task.name,
triggers: ['create'],
url: 'https://hooks.example/probe',
};

/**
* One row per semantic refusal site. `refused` and `accepted` differ by the one
* detail the site checks; `finding` is a verbatim fragment of the one `issues`
Expand Down Expand Up @@ -152,6 +162,14 @@ const rows: Array<{
accepted: { manifest, objects: [task], requires: ['automation', 'triggers'], flows: [recordFlow] },
finding: "flow 'task_fanout' declares a 'record_change' trigger",
},
{
site: 'webhook capability — a declared webhook without realtime (ruling A on #24910)',
code: 'STACK_WEBHOOK_CAPABILITY_REQUIRED',
header: 'defineStack webhook capability validation failed',
refused: { manifest, objects: [task], requires: ['webhooks'], webhooks: [taskWebhook] },
accepted: { manifest, objects: [task], requires: ['webhooks', 'realtime'], webhooks: [taskWebhook] },
finding: "webhook 'probe_task_changed' is declared but `requires` does not include 'realtime'",
},
{
site: 'unmanaged install mode — `unmanaged` permitted on a package the copy cannot write (ADR-0131 D6)',
code: 'STACK_UNMANAGED_INSTALL_UNCOPYABLE',
Expand Down Expand Up @@ -237,18 +255,18 @@ describe('every defineStack refusal carries an ADR-0112 envelope', () => {
});
});

describe('census over all eight refusal sites', () => {
describe('census over all nine refusal sites', () => {
const crossReference = { manifest, objects: [task], data: [{ object: 'missing_object', records: [] }] };
const everySite = [...rows.map((r) => r.refused), { manifest: {} }, crossReference];

it('eight sites, eight distinct STACK_* codes, every status 422', () => {
it('nine sites, nine distinct STACK_* codes, every status 422', () => {
const envelopes = everySite.map((config) => refusal(config));
for (const envelope of envelopes) {
expect(envelope).toBeInstanceOf(Error);
expect(envelope?.code).toMatch(/^STACK_[A-Z_]+$/);
expect(envelope?.status).toBe(422);
}
expect(new Set(envelopes.map((e) => e?.code)).size).toBe(8);
expect(new Set(envelopes.map((e) => e?.code)).size).toBe(9);
expect(envelopes.map((e) => e?.code)).toContain('STACK_CROSS_REFERENCE_INVALID');
});

Expand Down
Loading
Loading