Repository navigation
Commit 191e043
feat(plugin-webhooks,runtime)!: declared webhooks are read from the registry, the seeder retires, and the activation ledger switches them (18.x step 1, 8b-0g′) (#24588)
Closes #24529
Clause-②: yes (narrowing)
18.x line, step 1 (8b-0g′). Ruling #24435 Q2 A (6259141885), under
ruling records 6266750393 and 6267391172 on #15194. Stage detail: plan
amendment 13 (6259616282) on #15212. **Merge gate:** this PR stays a
draft until #24522's upgrade harness has merged; it then merges `main`
and must be green on R1–R4.
## What changed
**Declared webhooks are read from the registry.**
`bootstrapDeclaredWebhooks` is deleted. A fresh boot writes zero
`sys_webhook` rows for a declared webhook. ADR-0131 D2's sentence "No
seeder writes them into any table" is now true of webhooks.
- `plugin-webhooks/src/declared-webhooks.ts` (new).
`DeclaredWebhookSource` reads the `webhook` registry items, falling back
to the metadata service, as the seeder did. It parses each with
`WebhookSchema` (an invalid item warns once per name) and resolves the
activation-ledger switch.
- `AutoEnqueuer` takes a `declaredWebhooks` reader. On every refresh it
projects each declaration that is switched on into a cached subscription
through `parseRow`, so the trigger drift-guard and the
`webhook/without-triggers` refusal apply as they do to a row. The secret
and headers come from the declaration in memory and are never stored.
The subscription id and every delivery's `ref_id` are `declared:NAME`.
- The plugin binds the reader as the `declaredWebhooks` service, on the
data engine alone (no realtime needed). The dispatcher, the redelivery
veto and the activation door share it.
**The switch: regime C (ADR-0131 D6), as `sharing_rule` did in #24428.**
- `sys_metadata_activation` rows of type `webhook` override the declared
`isActive` in either direction. A webhook shipped inactive is switched
on the same way.
- The door is `runtime`'s catalog activation door, `POST
/api/v1/security/_activation/webhook/:name`. `webhook` is admitted next
to `sharing_rule`, and the two service-backed types now share one lookup
table. The authority tiers are unchanged. The door answers `404` for an
undeclared name, and `503` when no reader is bound or the read fails.
- A ledger write refreshes the dispatcher in the same request, through a
third realtime subscription on the ledger.
- The redelivery veto judges a `declared:NAME` delivery by its
declaration: not declared any more, or switched off, is refused.
**The organization dimension (measured, then fixed in this PR).** On
`main` (18.0, after 8b), the seeded `sys_webhook` row is stamped with
the Default Organization. Measured on a fresh `main` showcase:
`organization_id: org_…`. So once activated, it delivered under
`single`, because record events there carry that organization too. An
org-less declaration would have been refused by `admitsOrganization`,
which is a silent loss.
- Under a posture with no wall (the `tenancy` service, read as the
sharing plugin reads it), a declared webhook is **deployment-wide**. It
receives every organization's events, and each delivery is stamped with
the event's organization, as the 18.0 seeded copy's deliveries were.
- Under a wall, the org-less refusal (the 2026-09-07 ruling) stands. An
absent or unreadable posture reads as walled.
## 18.0-row behaviour (expand first: nothing is moved or deleted)
The 18.0 seeder's rows stay. A `sys_webhook` row holding a declared name
is read the way the 18.0 seeder would have treated it on this boot
(`classifyDeclaredNameHolder`). **Choice, measured: the seeded row is
recognised and ignored in favour of the registry entry when it is
pristine, and the registry entry defers to it when it is not.**
| row | 18.0 seeder did | this release |
|---|---|---|
| pristine mirror (`managed_by: 'package'`, not `customized`) |
overwrote it from the declaration every boot | ignored; the declaration
answers. One delivery, never two. It follows the declaration's URL and
switch. |
| `customized: true` (an admin edited the seeded row) | never touched it
| answers as itself; the declaration is withheld (`warn` once per name).
An admin's `active: false` keeps holding. |
| `managed_by: 'admin'` under a declared name | skipped with a warning |
answers as itself; the declaration is withheld. |
| pristine mirror whose declaration is gone | left it dispatching |
keeps dispatching. |
| an old delivery of a pristine mirror, redelivered | judged by the row
| judged by the declaration that now answers for it |
The 18.0-shaped fixture is `seeded18_0Row` in
`declared-webhooks.test.ts`, byte-for-byte the seeder's
`mapWebhookToRow` plus provenance. `webhook-secret-at-rest.test.ts`'s
`seed18_0Rows` writes the same rows through the real `ObjectQL` engine,
so both credentials land encrypted as they did.
## Ceremony debt (step 5), table by table
- **`sys_webhook`:** delete the pristine declared mirrors (`managed_by`
in `package`/`platform`, `customized` not true, name still declared).
Settle the customized seeded rows, as the sharing-rule ceremony settles
its customised copies: a copy under a new name plus the declaration
switched off. Then turn the `organization-ownership-inventory.ts` row's
`declared` category into a `mirror-deletion` departure (ADR-0131 D10
fate 2). That edit is ceremony behaviour (the apply executes
departures), so this PR does not make it.
- **`sys_http_delivery`:** re-source the deleted mirrors' tails from
`ref_id = MIRROR_ROW_ID` to `declared:NAME`, so history and redelivery
follow the declaration.
- **`sys_secret`:** delete the cipher rows the deleted mirrors'
`signing_secret` / `headers_secret` referenced. They become orphans.
- **`sys_webhook` columns `managed_by` / `customized`:** the mirror-row
edit path "goes with the mirrors" (Q2 A). Once no `package` row exists,
`customized` and the `package`/`platform` values of `managed_by`
describe nothing. Their help text still mentions boot seeding (left so
the i18n bundles are not churned here).
- **Code owed at step 7:** the dual read (`classifyDeclaredNameHolder`,
the mirror skip, the guard's mirror branch), the provenance stamp, and
`migrateLegacyWebhookSecrets`'s package-row half.
## Tests
Run at head `6b3042a8` (the final commit before this body).
- `@objectstack/plugin-webhooks`: 19 files, 212 tests, pass. `typecheck`
exit 0 (`check:test-typecheck` OK).
- `declared-webhooks.test.ts` (new, 24): the fresh-install shape,
including a plugin boot that records zero `sys_webhook` writes; the
ledger switch both ways; the ledger self-heal; a failed ledger read; the
posture dimension; the 18.0 dual read (seven cases); the redelivery
veto.
- `webhook-secret-at-rest.test.ts`: a declared webhook signs
byte-identically with no row and no cipher stored, and needs no
CryptoProvider. The at-rest, sweep and fail-closed pins now run over
18.0-seeded rows.
- Removed with the seeder: the four re-seed / rotate-in-code tests,
`bootstrap-declared-webhooks.test.ts`, and the gate test of the seeder's
own write path.
- `@objectstack/runtime`: full suite, 422 files, 6652 passed, 19
skipped. `typecheck` exit 0. `catalog-activation-door.test.ts` gains 7
`webhook` pins.
- Dogfood ADR-0054 proof `webhook-materialization.dogfood.test.ts`: 1/1
pass. It now asserts no `sys_webhook` row, plus a dispatch through the
bound `declaredWebhooks` service with `object`→`object_name` applied.
- `check:liveness` passes. The ledger is re-anchored to the registry
read. `label` and `description` are now `dead`: their only reader was
the seeded Setup row.
**Ablations** (`scripts/ablation-replace.mjs`; each leg restored, blob
== HEAD, `git diff HEAD` empty; the tests import `src/` relatively):
| ablated | red |
|---|---|
| the mirror skip in `doRefresh` | 3 |
| the withheld-declaration rule | 3 |
| the ledger override in `DeclaredWebhookSource.list` | 6 |
| the ledger self-heal handler | 1 |
| `deploymentWide` in `admitsOrganization` | 2 |
| the plugin's posture read | 1 |
| the guard's `declared:` branch | 2 |
| the guard's pristine-mirror branch | 1 |
| the door's `declaredWebhooks` lookup | 5 |
**Fresh showcase boot** at `6b3042a8`, `single`:
- `GET /data/sys_webhook` returns 0 rows.
- `POST /security/_activation/webhook/showcase_task_changed
{enabled:true}` returns 200, and one ledger row (`package_id:
com.example.showcase`).
- An undeclared name returns 404.
- A task create gives one `sys_http_delivery` row with `ref_id:
declared:showcase_task_changed`, stamped with the Default Organization.
- Switched off, a second create adds no delivery.
The same steps on a fresh `main` showcase gave the seeded row
(`organization_id` stamped) and its delivery (`ref_id: whk_…`, same
organization).
## Gates
Run locally at `6b3042a8`. The commands are the 130 that `dispatch-gates
--commands` derived, plus the 50-family artifact-roster block, 178 in
all. Exit codes were captured to a file before any pipe.
- **173 exited 0.** This includes the families this diff touched:
`check:liveness`, `check:tenant-audit-census`,
`check:engine-double-contract`, `check:objectql-double-limit`,
`check:where-matcher`, `check:adr-0087-registration`,
`check:changeset-no-major`, `check:platform-checklist`, and the spec
`check:generated` set.
- `--ran` reports 130 derived and 130 run, with 0 NOT-MEASURED.
- **NOT MEASURED, five:**
- `check-closing-target-claim`, `check-single-claim-paths` and
`check-governed-queue-guard` need PR or event context, which a local run
does not have. CI runs them.
- `check-partof-closing-keyword` needs the same. Run with `PR_BODY` set
to this body, it is reported in the os-dev report on #24529.
- `check-plugin-teardown-shape --self-test` needs the unshallowed
history its fixture is pinned to. It is checker health only, not a PR
verdict.
## Acceptance notes
- **Files outside the claimed surface.** Each is a consequence of
deleting the seeder, and none is #24522's harness, an ADR or a releases
page:
- `packages/spec`: liveness ledger rows, comments, and the
`WebhookSchema` doc block;
- `packages/qa/dogfood`: the webhook proof and its fixture (not #24522's
harness);
- `docs/qa/platform-checklist` (revision 8);
- `content/docs/automation/webhooks.mdx`, plus `tenant-audit-census.mdx`
and its counts file (regenerated, 231 to 229: the seeder's two writes);
- the showcase docblock;
- four `scripts/*` ledgers, re-keyed for the renamed test file or shrunk
for the deleted source.
- `organization-ownership-inventory.ts` is unchanged: see the ceremony
debt above.
- **Not added: a 409 name guard.** Nothing refuses a new `sys_webhook`
row under a declared name, as `sharing_rule` has. Expand-first adds no
new refusal. Such a row answers as itself and the declaration is
withheld, which is what the 18.0 seeder did with it.
- `label` / `description` of a declared webhook have no reader.
Enforce-or-remove (ADR-0049) is owed: a Setup surface that lists
declared webhooks, or retirement.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent bfc1539 commit 191e043
42 files changed
Lines changed: 1958 additions & 1144 deletions
File tree
- .changeset
- content/docs
- automation
- permissions
- docs
- audits
- qa/platform-checklist/areas
- examples/app-showcase/src/automation/webhooks
- packages
- lint/src
- plugins/plugin-webhooks/src
- qa/dogfood/test
- fixtures
- runtime/src
- domains
- spec
- liveness
- state-counts
- src
- automation
- migrations/entries/retired-keys
- scripts
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
80 | 80 | | |
81 | 81 | | |
82 | 82 | | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
83 | 111 | | |
84 | 112 | | |
85 | | - | |
| 113 | + | |
| 114 | + | |
86 | 115 | | |
87 | 116 | | |
88 | 117 | | |
| |||
149 | 178 | | |
150 | 179 | | |
151 | 180 | | |
152 | | - | |
153 | | - | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
154 | 184 | | |
155 | 185 | | |
156 | 186 | | |
| |||
366 | 396 | | |
367 | 397 | | |
368 | 398 | | |
369 | | - | |
370 | | - | |
371 | | - | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
372 | 402 | | |
373 | 403 | | |
374 | 404 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
125 | | - | |
| 125 | + | |
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| |||
150 | 150 | | |
151 | 151 | | |
152 | 152 | | |
153 | | - | |
154 | | - | |
| 153 | + | |
| 154 | + | |
155 | 155 | | |
156 | 156 | | |
157 | 157 | | |
| |||
187 | 187 | | |
188 | 188 | | |
189 | 189 | | |
190 | | - | |
191 | | - | |
192 | | - | |
193 | | - | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
194 | 194 | | |
195 | 195 | | |
196 | 196 | | |
| |||
201 | 201 | | |
202 | 202 | | |
203 | 203 | | |
204 | | - | |
| 204 | + | |
205 | 205 | | |
206 | 206 | | |
207 | 207 | | |
208 | 208 | | |
209 | 209 | | |
210 | | - | |
| 210 | + | |
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
214 | | - | |
| 214 | + | |
215 | 215 | | |
216 | 216 | | |
217 | | - | |
| 217 | + | |
218 | 218 | | |
219 | 219 | | |
220 | 220 | | |
| |||
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
226 | | - | |
227 | | - | |
| 226 | + | |
| 227 | + | |
228 | 228 | | |
229 | | - | |
| 229 | + | |
230 | 230 | | |
231 | 231 | | |
232 | 232 | | |
233 | 233 | | |
234 | | - | |
235 | | - | |
| 234 | + | |
| 235 | + | |
236 | 236 | | |
237 | 237 | | |
238 | | - | |
| 238 | + | |
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
242 | | - | |
| 242 | + | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
248 | | - | |
| 248 | + | |
249 | 249 | | |
250 | 250 | | |
251 | 251 | | |
| |||
297 | 297 | | |
298 | 298 | | |
299 | 299 | | |
300 | | - | |
| 300 | + | |
301 | 301 | | |
302 | 302 | | |
303 | 303 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | | - | |
| 39 | + | |
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
44 | | - | |
45 | | - | |
| 44 | + | |
| 45 | + | |
46 | 46 | | |
47 | 47 | | |
48 | | - | |
| 48 | + | |
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
| |||
183 | 183 | | |
184 | 184 | | |
185 | 185 | | |
186 | | - | |
187 | | - | |
188 | 186 | | |
189 | 187 | | |
190 | 188 | | |
| |||
0 commit comments