Skip to content

Commit baedcec

Browse files
feat(spec)!: defineStack refuses a declared webhook whose requires lacks webhooks or realtime (STACK_WEBHOOK_CAPABILITY_REQUIRED) (#25057)
Fixes #24997 Refs #24910 Clause-②: no (narrowing: a stack declaring a webhook without the capability tokens is refused; ships now under the amended 「甲」, #6015 `6303865025`) ## What the ruling binds Ruling A on #24910 (record `6303779439`, amended by `6303859748`: 「暂时不考虑 v19,协议的调整原则上也是尽快,不考虑退役版本。」), as the card states it: > - `defineStack` refuses a stack that declares a webhook while `requires` lacks `'webhooks'` or `'realtime'`, with ONE code for every arm, `STACK_WEBHOOK_CAPABILITY_REQUIRED`, in the `STACK_*` family beside `STACK_TRIGGER_CAPABILITY_REQUIRED` (`packages/spec/src/stack.zod.ts:3263`, #20332): which token is missing is in the finding, not in the code. Registered in `packages/spec/src/api/error-code-ledger.zod.ts`. > - Every door that loads a stack refuses with the same sentence: `os validate`, `os build`, `os serve` / `os dev`, an app's own `bootStack` tests, the cloud runtime. The boot `error` of PR #24946 (`19e5338cc8`) stays for a host that wires a stack by hand. > - **Measure first, at claim:** cloud's `resolveCapabilityDependencies` for `webhooks` (the trigger rule measured it for #20332); the dogfood fixture `packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts` (boots without realtime on purpose) is converted or declared, not deleted; the example apps' `requires` re-checked (`examples/app-showcase` already carries both tokens). > - ⛔ No token implied or auto-added; ⛔ the activation door is unchanged (A′ was not taken on #24910). ## What changed - `packages/spec/src/stack.zod.ts`: `validateWebhookCapability`, the third declared-capability refusal, follows the `validateTriggerCapability` precedent exactly. It runs right after the trigger check and throws `StackWebhookCapabilityRequiredError` (`code: 'STACK_WEBHOOK_CAPABILITY_REQUIRED'`, `status: 422`, the `StackRefusalError` envelope) with the header `defineStack webhook capability validation failed (N issues):` and one `issues` entry per declared webhook. Each entry names the webhook, the missing token or tokens, and a prescription that is the whole fix for the `requires` it was given: - `realtime` present, `webhooks` missing: add `'webhooks'`; - `webhooks` present, `realtime` missing: add `'realtime'`; - neither (an absent `requires` included): add both, `['webhooks', 'realtime']`. - Every declared webhook counts, `isActive: false` included. Unlike a retired flow, an inactive declaration is one activation-door write away from being switched on (the ledger row overrides the declared `isActive`, ADR-0131 D6), and the switch would then deliver nothing. Only the top-level `webhooks:` collection is read, the one the declared-webhook reader dispatches. Nothing is implied or added; `strict: false` skips the judgement by contract, as for every sibling. - `packages/spec/src/api/error-code-ledger.zod.ts`: the new code is registered under `@objectstack/spec` beside the other `STACK_*` rows, with `door: 'none'` on their reading. The generated `content/docs/references/api/error-code-ledger.mdx` and `contract.mdx` are regenerated (`check:generated --fix`, which proved only those stale). - `content/docs/automation/webhooks.mdx` §3.0 now says that such a stack is **refused**, names `'webhooks'` and `'realtime'`, says that every door stops with the same sentence, and says that the boot `error` remains for a host that wires a stack by hand. - Changeset `.changeset/24997-webhook-capability-required.md`: `@objectstack/spec` minor, BREAKING banner, `Clause-②: no (narrowing)`, the `adr-0087: not-required (no-migration-prescription)` marker. Same shape as the 24813 changeset. ## Producers measured and fixed | producer (tree `3b3830ca22`) | declares a webhook | `requires` | action | |:--|:--|:--|:--| | `examples/app-showcase` | yes (`allWebhooks`) | carries `webhooks` and `realtime` | none; still loads | | `examples/app-crm`, `app-todo`, `app-multi-package`, `embed-objectql` | no (`app-crm` names webhooks in a comment only) | n/a | none | | `create-objectstack` blank template | no | `['automation', 'triggers']` | none; refused once a webhook is added, which is the point | | `packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts` | yes | absent | **declared**, see below | | `packages/spec/src/stack-duplicate-entry.test.ts` webhook row | yes | absent | row context now carries `requires: ['webhooks', 'realtime']` | | `packages/qa/dogfood/test/webhook-meta-door-credential-redaction.dogfood.test.ts` (landed on `main` after the branch point) | yes | absent | **declared via `strict: false`**, because its "without plugin-webhooks" composition is what the redactor pins need. Same choice as the materialization fixture. Commit `779ed238e5` | Search used to find producers: every file that calls `defineStack` / `bootStack` / `composeStacks` and names `webhooks`, plus `defineWebhook` and `webhooks: [` repo-wide. The plugin-webhooks tests build declarations through `DeclaredWebhookSource`, never through `defineStack`, so they are unaffected. **The dogfood fixture is declared, not converted.** It is now built with `defineStack(..., { strict: false })`, and a docblock says why. The proof exists to show that the declared-webhook reader binds on the data engine alone, with NO realtime service. If the fixture declared the pair, `bootStack` would mount realtime the way `serve` does, and a reader re-gated behind the dispatch prerequisites would then pass unseen. So the fixture models the one composition `defineStack` cannot reach: a hand-wired host. That is the population the boot `error` of #24946 serves. The precedent for a dogfood fixture built `strict: false` on purpose is `flow-node-config-values-at-registration.dogfood.test.ts`. Its second consumer, `wall-declared-webhook-deployment-wide.dogfood.test.ts`, hand-wires realtime and is unaffected. Both suites pass. **Cloud's `resolveCapabilityDependencies`: NOT MEASURED.** Reason: the `cloud` repository is not in this container. The refusal sits in `defineStack`, so the cloud runtime refuses with the same sentence on any stack it loads through the producer. Whether its resolver implies `realtime` from `webhooks` changes nothing here, because no token is implied by ruling. ## The doors, measured (scaffold-shaped project, `requires: ['automation', 'triggers']` plus one declared webhook; CLI from source, `bin/run-dev.js`) | `requires` | `os validate` | `os build --json` | `os dev --no-watch --fresh` | |:--|:--|:--|:--| | `['automation', 'triggers']` | exit 1, `--json` `"code": "STACK_WEBHOOK_CAPABILITY_REQUIRED"`, finding: `webhook 'blank_note_changed' is declared but `requires` does not include 'webhooks' or 'realtime'` | exit 1, `"success": false`, `"code": "STACK_WEBHOOK_CAPABILITY_REQUIRED"` | exit 1 at `Loading objectstack.config.ts...`, same header and finding | | `[..., 'webhooks']` | exit 1, finding names `'realtime'` | not run | not run | | `[..., 'realtime']` | exit 1, finding names `'webhooks'` | not run | not run | | `['automation', 'triggers', 'webhooks', 'realtime']` (CONTROL) | exit 0 | exit 0, `"success": true` | `Server is ready`, `/api/v1/health` 200, `WebhookOutboxPlugin` and `RealtimeServicePlugin` in the plugin list; stopped by its recorded PID | An app's `bootStack` tests hit the same refusal at import, because the throw is in `defineStack` itself. ## Tests (pinned arms, control, ablation) - New `packages/spec/src/stack-webhook-capability.test.ts`. It pins: the three arms (no `webhooks`, no `realtime`, both missing; for both missing, `requires` of `['automation', 'triggers']`, `[]` and absent), each with `code`, `status: 422`, the header, one `issues` entry and the finding text naming the webhook and the token(s); the CONTROL (the scaffold tokens plus the pair, the pair in either order, among other tokens); each prescription applied once is accepted; no token is implied (an accepted stack keeps the `requires` it declared); a stack with no webhook owes nothing; an inactive declaration still owes the pair; every webhook reported, one entry each; the map form; the trigger sibling reports first; `strict: false` skips. - `stack-refusal-envelopes.test.ts`: a webhook row (refused + accepted control); the census is now nine sites and nine distinct codes. `stack-conversions-record.test.ts`: a webhook row; its census is now nine codes. - **Ablation, red:** `node scripts/ablation-replace.mjs` replaced the call `const webhookErrors = validateWebhookCapability(data);` with an empty list. The tool's own on-disk evidence: anchor 1 to 0, blob `3d8624dc4fa1` to `5898d7c8a60e`. With the call gone, the three spec files gave **18 failed / 76 passed**. Restore: blob equals HEAD `3d8624dc4fa1` and `git diff HEAD` is empty. No dist rebuild was needed, because these tests import `./stack.zod` from source. ## Verification - **Resumed after a container restart:** merged `origin/main` `63085c3806`. Regenerated `content/docs/references/api/automation-api.mdx` (a page added on `main` that lists every error code), commit `e69078f0d1`. At `779ed238e5`: spec `check:generated` up to date, spec tests 21446 passed, spec `test:repo` 1237 passed, the three webhook-declaring dogfood files 24 of 24 passed, and `dispatch-gates --ran` shows 116 derived and 116 run. Report comment `6309741302` on #24997. Run at `5107a88d13` (before the `origin/main` merge) unless stated otherwise: - `pnpm --filter @objectstack/spec test`: 703/704 files passed on the first run. The one failure was the conversions-record census (`8` to `9`), fixed; the four affected files then gave 232/232. `test:repo`: 72 files / 1236 tests passed. `typecheck`: passed (`check:test-typecheck: OK`). `check:generated`: all 14 artifacts up to date after `--fix` regenerated the two reference pages. - `@objectstack/dogfood`: `webhook-materialization.dogfood.test.ts` and `wall-declared-webhook-deployment-wide.dogfood.test.ts`, 9/9 passed. - `@objectstack/cli` `--project unit`: 313/316 files passed. The 3 failures (`published-subpath-console.pin`, `published-subpath-hook-body.pin`, `scaffold-emission-typechecks`) read packed `.d.ts`, and an `OS_SKIP_DTS` closure build had removed them (TS7016 "Could not find a declaration file"). After a full declaration build of `@objectstack/cli...`, the three files gave 42/42. Integration layer: declared to CI (this diff touches no cli file). - Lint: `eslint --no-inline-config --format json` over the 7 touched `.ts` files gave 7 files, 0 errors, 0 warnings. Population: all 7 resolve a config (`--print-config`). Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. Repo-wide `pnpm lint` belongs to CI. - `node scripts/pm/dispatch-gates.mjs --commands`: 116 families derived at `5107a88d13`, all 116 run, all exit 0. Seven of them first answered PREREQUISITE NOT MET because of the same skip-DTS build, and gave exit 0 on re-run after the declaration build: `check:api-surface`, `check:dual-source-exports`, `check:entry-nameability`, `check:exported-any`, `check:skill-examples`, `check:dts-closure`, `check:dual-build-cjs-loads`. - After merging `origin/main` (14 commits, file-disjoint from this diff): spec build + `check:generated` + spec `test` were re-run on the merge head; see the report comment for that reading. ## Acceptance notes - Text-mode `os validate` and `os dev` print the refusal's message, header and finding, but not the `code`. That is how every `STACK_*` refusal surfaces today, the trigger sibling included; the code is on `--json` (`os validate --json`, `os build --json`). Carrier: none. The CLI's text renderer is not this card's surface. - `skills/objectstack-automation/SKILL.md`, the capability table, still reads `webhooks`, absent, "nothing delivered outbound", and does not name `realtime`. Since this change, a stack in that state is refused with the fix named, so this is no silent trap. `skills/**` is governed (Tier H) and outside the claim's file surface, so it is not edited here. Carrier: none. - Contract review at `CONTRACT_REVIEW_TIER` is owed on this PR (the claim and the ruling both say so). --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2b1382a commit baedcec

13 files changed

Lines changed: 376 additions & 17 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: `defineStack` refuses a stack that declares a webhook while `requires` lacks `'webhooks'` or `'realtime'`, with `STACK_WEBHOOK_CAPABILITY_REQUIRED`
6+
7+
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.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- 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). -->
12+
13+
**BREAKING** (the `defineStack` accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
- **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.
16+
- **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.
17+
- **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.
18+
- **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.

‎content/docs/automation/webhooks.mdx‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,13 @@ A webhook reaches the dispatcher one of two ways:
101101
own `active` field.
102102

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

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

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

125135
On a database an 18.0 pre-release (`18.0.0-next.*`) created, the rows that release's boot seeder
126136
wrote for declared webhooks (`managed_by: 'package'`) are still there. Until the

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ const result = AutomationApiErrorCode.parse(data);
352352

353353
| Property | Type | Required | Description |
354354
| :--- | :--- | :--- | :--- |
355-
| **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 |
355+
| **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 |
356356
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
357357

358358
### Allowed Values: `FlowInnerRefusal.code`
@@ -673,6 +673,7 @@ const result = AutomationApiErrorCode.parse(data);
673673
* `STACK_SINGLE_APP_VIOLATION`
674674
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
675675
* `STACK_UNMANAGED_INSTALL_UNCOPYABLE`
676+
* `STACK_WEBHOOK_CAPABILITY_REQUIRED`
676677
* `STORED_TYPE_NOT_CANONICAL`
677678
* `STORE_UNAVAILABLE`
678679
* `SUGGESTION_CONFIRM_FAILED`

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **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) |
31+
| **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) |
3232
| **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) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **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`. |
@@ -356,6 +356,7 @@ const result = ApiErrorSchema.parse(data);
356356
* `STACK_SINGLE_APP_VIOLATION`
357357
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
358358
* `STACK_UNMANAGED_INSTALL_UNCOPYABLE`
359+
* `STACK_WEBHOOK_CAPABILITY_REQUIRED`
359360
* `STORED_TYPE_NOT_CANONICAL`
360361
* `STORE_UNAVAILABLE`
361362
* `SUGGESTION_CONFIRM_FAILED`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,7 @@ const result = ErrorCode.parse(data);
523523
* `STACK_SINGLE_APP_VIOLATION`
524524
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
525525
* `STACK_UNMANAGED_INSTALL_UNCOPYABLE`
526+
* `STACK_WEBHOOK_CAPABILITY_REQUIRED`
526527
* `STORED_TYPE_NOT_CANONICAL`
527528
* `STORE_UNAVAILABLE`
528529
* `SUGGESTION_CONFIRM_FAILED`

‎packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,20 @@ export const wmTaskChanged = defineWebhook({
4646
isActive: true,
4747
});
4848

49+
/**
50+
* Built with `strict: false`, on purpose — DECLARED, not an oversight. This
51+
* stack declares a webhook and no `requires`, so `defineStack`'s default
52+
* judgement refuses it (`STACK_WEBHOOK_CAPABILITY_REQUIRED`: a declared webhook
53+
* owes `requires: ['webhooks', 'realtime']`, ruling A on #24910), and every
54+
* authoring door would stop it at load. What this fixture models is the one
55+
* composition that judgement cannot reach: a host that wires the stack by hand,
56+
* with the webhook plugin and NO realtime service, which is exactly what the
57+
* proof needs — the declared-webhook reader must bind on the data engine alone.
58+
* Declaring the pair instead would have `bootStack` mount realtime the way
59+
* `serve` does, and a reader re-gated behind the dispatch prerequisites would
60+
* then pass unseen. Everything else about the stack is an ordinary authored
61+
* package.
62+
*/
4963
export const webhookFixtureStack = defineStack({
5064
manifest: {
5165
id: 'com.dogfood.webhook-fixture',
@@ -57,4 +71,4 @@ export const webhookFixtureStack = defineStack({
5771
},
5872
objects: [WmTask],
5973
webhooks: [wmTaskChanged],
60-
});
74+
}, { strict: false });

‎packages/qa/dogfood/test/webhook-meta-door-credential-redaction.dogfood.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ const WcrTask = ObjectSchema.create({
5757
fields: { name: Field.text({ label: 'Name', required: true }) },
5858
});
5959

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

8497
const savedBody = () => ({
8598
name: SAVED,

‎packages/spec/src/api/error-code-ledger.zod.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1554,6 +1554,13 @@ export const ERROR_CODE_LEDGER = {
15541554
// of another package's items. Its install-time counterpart is
15551555
// `UNMANAGED_INSTALL_UNCOPYABLE` under `@objectstack/metadata-protocol`.
15561556
'STACK_UNMANAGED_INSTALL_UNCOPYABLE',
1557+
// [ruling A on #24910] The third declared-capability refusal of the same
1558+
// family and reading (`door: 'none'`, `status: 422`), beside
1559+
// `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` and
1560+
// `STACK_TRIGGER_CAPABILITY_REQUIRED`: a declared webhook while `requires`
1561+
// omits `webhooks` or `realtime`, the pair that dispatches it. One code for
1562+
// every arm; which token is missing is in the finding.
1563+
'STACK_WEBHOOK_CAPABILITY_REQUIRED',
15571564
// [#16348] The COMPOSITION half of the same family, and `door: 'none'` on
15581565
// the same reading: the six `composeStacks` refusals, one code per raise
15591566
// site, every one `status: 422` (`StackRefusalError`, `stack.zod.ts`), the

‎packages/spec/src/stack-conversions-record.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,17 @@ describe('every refusal site in defineStack carries the record — census, both
533533
code: 'STACK_TRIGGER_CAPABILITY_REQUIRED',
534534
config: { manifest, objects: [task], pages, requires: ['automation'], flows: [recordFlow] },
535535
},
536+
{
537+
site: 'webhook capability',
538+
code: 'STACK_WEBHOOK_CAPABILITY_REQUIRED',
539+
config: {
540+
manifest,
541+
objects: [task],
542+
pages,
543+
requires: ['webhooks'],
544+
webhooks: [{ name: 'probe_hook', label: 'Hook', object: task.name, triggers: ['create'], url: 'https://hooks.example/probe' }],
545+
},
546+
},
536547
{
537548
site: 'unmanaged install mode',
538549
code: 'STACK_UNMANAGED_INSTALL_UNCOPYABLE',
@@ -553,8 +564,8 @@ describe('every refusal site in defineStack carries the record — census, both
553564
});
554565
}
555566

556-
it('the census reaches every code defineStack refuses with: eight distinct codes', () => {
557-
expect(new Set(rows.map((r) => r.code)).size).toBe(8);
567+
it('the census reaches every code defineStack refuses with: nine distinct codes', () => {
568+
expect(new Set(rows.map((r) => r.code)).size).toBe(9);
558569
});
559570
});
560571

‎packages/spec/src/stack-duplicate-entry.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ const BATCH: readonly Row[] = [
133133
byName('permissions', 'Permission set', { name: 'dupe_perm', label: 'Perm', objects: {} }),
134134
byName('capabilities', 'Capability', { name: 'dupe.export', label: 'Export' }),
135135
byName('sharingRules', 'Sharing rule', { name: 'dupe_share', label: 'Share', object: 'dupe_thing', type: 'criteria', condition: 'true', sharedWith: { type: 'team', value: 'team_ops' } }),
136-
byName('webhooks', 'Webhook', { name: 'dupe_webhook', label: 'Webhook', object: 'dupe_thing', triggers: ['create'], url: 'https://example.com/hook' }),
136+
// A declared webhook owes the pair that dispatches it (ruling A on #24910).
137+
byName('webhooks', 'Webhook', { name: 'dupe_webhook', label: 'Webhook', object: 'dupe_thing', triggers: ['create'], url: 'https://example.com/hook' }, { requires: ['webhooks', 'realtime'] }),
137138
byName('hooks', 'Hook', { name: 'dupe_hook', label: 'Hook', object: 'dupe_thing', events: ['beforeInsert'], handler: () => {} }),
138139
byName('mappings', 'Mapping', { name: 'dupe_map', label: 'Map', targetObject: 'dupe_thing', fieldMapping: [{ source: 'a', target: 'title' }] }),
139140
byName('analyticsCubes', 'Analytics cube', { name: 'dupe_cube', title: 'Cube', description: 'first', sql: 'dupe_thing', measures: {}, dimensions: {} }),

0 commit comments

Comments
 (0)