Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
420fb8c
wip(plugin-webhooks): declared webhooks read from the registry; seede…
claude Oct 19, 2026
e44c642
test(plugin-webhooks): pin the registry read, the ledger switch and t…
claude Oct 19, 2026
8189a84
wip: liveness ledger re-anchored to the registry read; dogfood proof …
claude Oct 19, 2026
e412cb8
docs+ledger: the registry read named across spec comments, liveness, …
claude Oct 19, 2026
bdad70b
spec(liveness): state counts regenerated; isActive named at the dispa…
claude Oct 19, 2026
723af97
fix(plugin-webhooks): a declared webhook is deployment-wide when the …
claude Oct 19, 2026
e0a6dc9
fix(gates): tenant-audit census regenerated for the seeder's two reti…
claude Oct 19, 2026
6b3042a
test(plugin-webhooks): the fake engine refuses a WHERE combinator it …
claude Oct 19, 2026
820121c
fix(lint,showcase): the dead label/description verdict is carried to …
claude Oct 19, 2026
b378ff5
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
c3b1452
test(dogfood): the R2 harness pins one delivery per task change from …
claude Oct 19, 2026
9bce0aa
test(dogfood): the harness case switches the declaration on, so it ca…
claude Oct 19, 2026
7e580dc
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
fa6a837
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
b4ae8e6
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
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
30 changes: 30 additions & 0 deletions .changeset/24529-declared-webhooks-from-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/plugin-webhooks': minor
'@objectstack/runtime': minor
'@objectstack/spec': patch
---

feat(plugin-webhooks,runtime)!: declared webhooks are read from the registry — the boot seeder retires, no `sys_webhook` row is written for one, and it is switched through the activation ledger

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) no spec key, export, option or stored metadata shape is added, removed or renamed, so `objectstack migrate meta` has nothing to rewrite; the rows an 18.0 seeder wrote stay in place and keep working, and what changes is where a declared webhook lives and how it is switched -->

**BREAKING** (a narrowing of where a declared webhook can be edited), shipped as `minor` under the launch-window convention for breaking changes.

ADR-0131 D2 says no seeder writes declared items into a table, and D6 charts behavioural types under regime C. A webhook declared with `defineStack({ webhooks })` now follows both, as declared sharing rules already do: the dispatcher reads the declaration from the metadata registry on every subscription-cache refresh, and `bootstrapDeclaredWebhooks` no longer runs.

What changes:

- **No `sys_webhook` row for a declared webhook.** Before: every boot wrote or refreshed one `managed_by: 'package'` row per declared webhook, shown and editable in Setup → Integrations → Webhooks. Now: a fresh install writes none, and Setup lists only the webhooks an organization authors. The declared webhook still delivers, with the same URL, method, triggers, `timeoutMs`, signing secret and headers; its secret and headers are code, held in memory, and never stored. Its deliveries carry `ref_id = declared:<name>`.
- **A declared webhook is read-only, and switched through the activation ledger.** Before: an administrator edited the seeded row in Setup (`customized: true`). Now: switch it on or off for the deployment with `POST /api/v1/security/_activation/webhook/<name>` and `{ "enabled": true | false }` — one `sys_metadata_activation` row of type `webhook`, which overrides the declared `isActive` in either direction, so a webhook shipped inactive is switched on the same way. The door is the declared-sharing-rule door with `webhook` admitted: `manage_metadata`, then the platform operator under a wall; `404` for a name nothing declares; `503` when no declared-webhook reader is bound or it cannot be read. The dispatcher refreshes in the request that wrote the row. Remedy for a change beyond on/off: author a copy under a new name as a `sys_webhook` row, and switch the declared one off.
- **`label` and `description` on a declared webhook are no longer shown anywhere.** The seeded row was their only reader. They are display-only, so no delivery changes; the liveness ledger marks them dead, owed enforce-or-remove.

On a database an 18.0 install created (expand first — nothing is moved or deleted):

- The rows the 18.0 seeder wrote stay where they are. A pristine one (`managed_by: 'package'`, not `customized`) is ignored in favour of its declaration, so a declared webhook is never delivered twice; it follows the declaration's URL and switch, as the seeder's rewrite on every boot made it do.
- A seeded row an administrator edited (`customized: true`), or a row an administrator authored under a declared name, answers as itself, and the declaration is not dispatched beside it — an administrator's `active: false` on a noisy webhook keeps holding. This is logged at `warn` once per name per process, with the remedy.
- A seeded row whose declaration was removed keeps delivering, as it did on 18.0.
- Redelivering an old delivery of a pristine seeded row is judged by the declaration that now answers for it, so a webhook switched off through the ledger is not re-sent.

Deleting the seeded rows, and re-pointing their `sys_http_delivery` history at the declaration, is a step of the ownership migration in a later 18.x release.
42 changes: 36 additions & 6 deletions content/docs/automation/webhooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,38 @@ Two persisted objects underpin the runtime, owned by different packages.
Both are defined as `ObjectSchema.create()` schemas so they participate in
CRUD, permissions, audit, and Studio UI without bespoke code.

### 3.0 Declared webhooks (code) and `sys_webhook` rows

A webhook reaches the dispatcher one of two ways:

- **Declared in code** — `defineStack({ webhooks })` / `defineWebhook()`. The
declaration is read from the metadata registry on every subscription-cache
refresh, and **no `sys_webhook` row is written for it** (ADR-0131 D2). Its
signing secret and headers are code too, held in memory with it and never
stored. A declared webhook is read-only: switch it on or off for the
deployment with
`POST /api/v1/security/_activation/webhook/:name` and body
`{ "enabled": true | false }`, which writes one `sys_metadata_activation` row
of type `webhook` and overrides the declared `isActive` in either direction
(ADR-0131 D6, regime C — the door, its authority tiers and its refusals are
the ones the declared-sharing-rule switch uses). To change one, author a copy
under a new name as a `sys_webhook` row and switch the declared one off.
Deliveries of a declared webhook carry `ref_id = declared:<name>`.
- **Authored by an organization** — a `sys_webhook` row (§3.1), switched by its
own `active` field.

On a database an 18.0 install created, the rows that release's boot seeder
wrote for declared webhooks (`managed_by: 'package'`) are still there. Until the
ownership migration removes them, a pristine one is ignored in favour of its
declaration, so the webhook is never delivered twice; one an administrator
edited (`customized: true`) answers as itself, and the declaration of the same
name is not dispatched beside it — an administrator's `active: false` keeps
holding.

### 3.1 `sys_webhook`

The subscription record. One row per "I want webhook X to fire for object Y".
The subscription record an organization authors. One row per "I want webhook
X to fire for object Y".
The authored envelope — the per-attempt `timeoutMs` and the rest of the
`Webhook` config — is carried in `definition_json`, a serialised `Webhook` JSON
(canonical schema: `WebhookSchema`, exported from
Expand Down Expand Up @@ -149,8 +178,9 @@ readable through an ordinary `GET /api/v1/data/sys_webhook`. They now go to
`signing_secret` and `headers_secret`; existing rows are migrated on boot by one
idempotent sweep that moves both passengers and strips them from the blob in a
single update. Authoring does not change — `defineWebhook({ secret, headers })`
is written exactly as before, and the boot materializer routes each value to its
own column.
is written exactly as before; a declared webhook's values are now read from the
declaration itself and stored nowhere (§3.0), and an authored row's go to their
own columns.

A row the sweep has not converted — for example a pre-v17 row on a runtime with
no `CryptoProvider` wired, where the encrypted write is refused and the row is
Expand Down Expand Up @@ -366,9 +396,9 @@ UNIQUE constraint.

For each incoming event the subscriber:

1. Loads `sys_webhook` rows where `active = true` AND `object_name`
matches the event's object AND the event's action is in the row's
`triggers`.
1. Loads `sys_webhook` rows where `active = true`, and the declared
webhooks switched on (§3.0), whose `object_name` matches the event's
object AND whose `triggers` include the event's action.
2. For each match, builds the payload (§5).
3. Enqueues a `sys_http_delivery` row (`source = 'webhook'`).

Expand Down
40 changes: 20 additions & 20 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **52 of the 232 sites are spelled that way**. A
forwarding shim cannot, and **50 of the 230 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand Down Expand Up @@ -150,8 +150,8 @@ now **0**: nothing on this surface threads a context that provably lacks the fla

**"No tenant context" counted sites it had not read.** An options argument the
walker could not parse was folded into the same bucket as one it had read and
found empty. That published **60 sites "carrying no tenant context at all"**
when 8 said so and 52 were simply unread — an over-claim in the *alarming*
found empty. That published **58 sites "carrying no tenant context at all"**
when 8 said so and 50 were simply unread — an over-claim in the *alarming*
direction, on the very figure this page tells other cards to cite. `carries` is
now three-valued, and an unreadable argument can never contribute to the
provable count.
Expand Down Expand Up @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **232** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **158 of 232** decidable, **74** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 129 decidably elevated, 0 decidably not, 95 undecidable |
| 175 write call sites | quoted in the merged changeset | **230** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 230** decidable, **74** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 129 decidably elevated, 0 decidably not, 93 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All @@ -201,20 +201,20 @@ at any commit.

Two structural facts do plausibly widen this reading against any hand or regex
one, and both are counted in the generated tables below: the 41 sites reached
through an erased (`any`) receiver, and the 51 that name their object through a
through an erased (`any`) receiver, and the 49 that name their object through a
`const` rather than inline. An instrument that read either the way a person does
would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
129 of 232 (56%) as decidably elevated, with 95 more whose elevation is a
129 of 230 (56%) as decidably elevated, with 93 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `2 / 232`, and say what it is**: the sites whose options argument was
⇒ **Cite `2 / 230`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **31 further sites** have an options argument this
without tenant context" — **29 further sites** have an options argument this
cannot read, and they are neither in nor out.

{/* BEGIN GENERATED: tenant-audit-census (scripts/tenant-audit-census.mjs) — DO NOT EDIT */}
Expand All @@ -223,29 +223,29 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **232** |
| …whose object name is statically decidable | 158 |
| write call sites on the application surface | **230** |
| …whose object name is statically decidable | 156 |
| …whose object name is chosen at run time | 74 |
| …against an object with tenancy ENABLED | 157 |
| …against an object with tenancy ENABLED | 155 |
| …against an object that declares tenancy off | 1 |
| threading a tenant context | 172 |
| PROVABLY carrying none (options read, no context key) | **8** |
| …of those, against a decidably tenancy-enabled object | **2** |
| options argument UNREADABLE — may or may not carry one | 52 |
| …of those, against a decidably tenancy-enabled object | 31 |
| options argument UNREADABLE — may or may not carry one | 50 |
| …of those, against a decidably tenancy-enabled object | 29 |
| threading a decidably ELEVATED (`isSystem`) context | 129 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 95 |
| threading a context whose elevation is a run-time fact | 93 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 191 |
| receiver carried a readable engine type | 189 |
| receiver erased, placed by the object NAME | 31 |
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

| object name spelled inline | 107 |
| object name spelled through a `const` | 51 |
| object name spelled through a `const` | 49 |
| object name is an `object: string` parameter | 7 |
| object name is some other run-time expression | 67 |

Expand Down Expand Up @@ -297,7 +297,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-10-19 at `030483e7f`.
Measured on 2026-10-19 at `fa6a837e7`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down
16 changes: 7 additions & 9 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 232 |
| Object name statically decidable | 158 |
| Write call sites | 230 |
| Object name statically decidable | 156 |
| Object name chosen at run time | 74 |
| Against a tenancy-enabled object | 157 |
| Against a tenancy-enabled object | 155 |
| Against an object declaring tenancy off | 1 |
| Threading a tenant context | 172 |
| Provably carrying none | 8 |
| …and decidably tenancy-enabled | 2 |
| Options argument unreadable | 52 |
| …and decidably tenancy-enabled | 31 |
| Options argument unreadable | 50 |
| …and decidably tenancy-enabled | 29 |
| Threading a decidably elevated context | 129 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 95 |
| Threading a context of undecidable elevation | 93 |

## Subtractions the census could NOT defend — enforced

Expand Down Expand Up @@ -90,7 +90,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-10-19 at `030483e7f`.
Measured on 2026-10-19 at `fa6a837e7`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down Expand Up @@ -183,8 +183,6 @@ Measured on 2026-10-19 at `030483e7f`.
| `packages/plugins/plugin-sharing/src/sharing-service.ts` | `delete` | `sys_record_share` | enabled | elevated | 2 |
| `packages/plugins/plugin-sharing/src/sharing-service.ts` | `insert` | `sys_record_share` | enabled | elevated | 1 |
| `packages/plugins/plugin-sharing/src/sharing-service.ts` | `update` | `sys_record_share` | enabled | elevated | 1 |
| `packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts` | `insert` | `sys_webhook` | enabled | options unreadable | 1 |
| `packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts` | `update` | `sys_webhook` | enabled | options unreadable | 1 |
| `packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts` | `update` | `subscriptionsObject` | undecidable | options unreadable | 1 |
| `packages/services/service-automation/src/builtin/crud-nodes.ts` | `delete` | `objectName` | undecidable | context, elevation undecidable | 1 |
| `packages/services/service-automation/src/builtin/crud-nodes.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 |
Expand Down
Loading
Loading