Skip to content

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

Merged
os-zhuang merged 15 commits into
mainfrom
claude/issue-24529-8b0g-webhooks-registry
Oct 19, 2026
Merged

os-zhuang merged 15 commits into
mainfrom
claude/issue-24529-8b0g-webhooks-registry

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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:

Acceptance notes


Generated by Claude Code

…r retired; webhook activation door

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…he 18.0 dual read; door pins for webhook

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…asserts no row and a dispatch

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…the checklist, the webhooks guide and the showcase; changeset

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…tcher's switch read

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…posture builds no wall, as its 18.0 seeded copy stamped with the Default Organization was

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…red writes; the declared-webhook double is drivable by the limit gate

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…does not implement

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 19, 2026
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-webhooks, @objectstack/runtime, @objectstack/spec, touching 73 documentable anchor(s). ⚠️ 8 changed file(s) yielded no anchor (packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts, packages/plugins/plugin-webhooks/src/webhook-headers-gate.ts, packages/plugins/plugin-webhooks/src/webhook-provenance.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json b12e16a561cae3da15c09b463b470ce760b11834.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 8 changed file(s) yielded no anchor (packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts, packages/plugins/plugin-webhooks/src/webhook-headers-gate.ts, packages/plugins/plugin-webhooks/src/webhook-provenance.ts, …) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: packageId (4 routes)
  • 22 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 148 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b12e16a561cae3da15c09b463b470ce760b11834 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cd534ecc718637e49ec42f3365d5b68311df243f — the merge of head b4ae8e68332554376921625922f126d6e1e0c09f into base b12e16a561cae3da15c09b463b470ce760b11834, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cd534ecc718637e49ec42f3365d5b68311df243f && git checkout cd534ecc718637e49ec42f3365d5b68311df243f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b12e16a561cae3da15c09b463b470ce760b11834 b4ae8e68332554376921625922f126d6e1e0c09f && git checkout -B drift-repro b12e16a561cae3da15c09b463b470ce760b11834 && git merge --no-ff b4ae8e68332554376921625922f126d6e1e0c09f

node scripts/docs-audit/affected-docs.mjs --json b12e16a561cae3da15c09b463b470ce760b11834

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b12e16a561cae3da15c09b463b470ce760b11834 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6b3042a841be557baf221e9443c9682046cb534d
Local-runs: none
Scope: packages/spec/src/automation/webhook.zod.ts, packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__webhooks.ts, packages/plugins/plugin-webhooks/src/declared-webhooks.ts, packages/plugins/plugin-webhooks/src/auto-enqueuer.ts, packages/plugins/plugin-webhooks/src/redeliver-guard.ts, packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts, packages/plugins/plugin-webhooks/src/webhook-provenance.ts, packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts, packages/plugins/plugin-webhooks/src/sys-webhook.object.ts, packages/plugins/plugin-webhooks/src/webhook-headers.ts, packages/plugins/plugin-webhooks/src/webhook-headers-gate.ts, packages/plugins/plugin-webhooks/src/webhook-secret.ts, packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts (deleted), packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts (deleted), packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts, packages/plugins/plugin-webhooks/src/declared-webhooks.connector-nested.test.ts, packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts, packages/plugins/plugin-webhooks/src/webhook-headers-gate.test.ts, packages/plugins/plugin-webhooks/src/plugin-shutdown-stops-auto-enqueuer.test.ts, packages/runtime/src/domains/catalog-activation.ts, packages/runtime/src/domains/activation-gate.ts, packages/runtime/src/domains/catalog-activation-door.test.ts, packages/runtime/src/domains/security.ts, packages/runtime/src/route-ledger.ts, packages/spec/liveness/webhook.json, packages/spec/liveness/connector.json, packages/spec/liveness/state-counts/webhook.md, packages/spec/src/automation/webhook.test.ts, packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts, packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts, examples/app-showcase/src/automation/webhooks/index.ts, scripts/engine-double-contract.baseline.json, scripts/engine-double-contract.pinned.json, scripts/objectql-double-limit.baseline.json, scripts/query-options-erasure-baseline.json, .changeset/24529-declared-webhooks-from-registry.md, docs/qa/platform-checklist/areas/integration-system.json + packages/lint/src/lint-liveness-properties.test.ts, packages/lint/src/lint-liveness-properties.ts, packages/plugins/plugin-sharing/src/declared-sharing-rules.ts, packages/plugins/plugin-sharing/src/sharing-rule-service.ts, packages/cli/src/utils/organization-ownership-inventory.ts, packages/cli/src/utils/organization-ownership-plan.ts, packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts, packages/qa/dogfood/test/upgrade-harness/fixture-18.0/manifest.json, packages/qa/dogfood/test/upgrade-harness/fixture-18.0/database.sql, packages/objectql/src/engine.ts, .github/workflows/ci.yml, scripts/check-changeset-no-major.mjs

Reviewed by an isolated at-tier reviewer subagent of the adopting seat (epic:#15194), read-only: the diff origin/main...refs/review/pr-24588 (merge-base 5b14b7f3ad8e), the card, the rulings named below, and the head's check-runs. The head did not move while this was written. Inputs: ruling records 6266750393, 6267391172 and 6270035914 on #15194; ruling 6259141885 on #24435 (Q2 A); the step-0 plan 6268559156 on #24522 (step_plan.steps[0], F1–F6, flags[1]); card #24529 with claim 6267446928 and os-dev-report 6270150096 (no later addendum); PR #24428 as the regime-C pattern; the merged harness.

① Derived judgments

  1. The seeder retires; D2 becomes true of webhooks — right. bootstrap-declared-webhooks.ts is deleted and WebhookOutboxPlugin.bootWebhookTable (webhook-outbox-plugin.ts:315-341) binds one DeclaredWebhookSource as the declaredWebhooks service on the data engine alone, before bootAutoEnqueue, so the activation door answers on a kernel that dispatches nothing. The read order is the seeder's (_registry.listItems('webhook'), then the metadata service, a promise not awaited — readDeclaredWebhookItems, declared-webhooks.ts:133); both _registry and registry resolve on ObjectQL (engine.ts:4688, :4725). Pinned: the plugin boot records zero sys_webhook writes (declared-webhooks.test.ts, "nothing writes a sys_webhook row"); the ADR-0054 proof boots the real stack without realtime and asserts no row, the bound reader, and a dispatch with object→object_name applied.
  2. Fresh install dispatch — right. projectDeclared (auto-enqueuer.ts:611) runs the declaration through parseRow, so the trigger drift-guard and webhook/without-triggers apply as to a row; secret and headers are lifted off the parsed declaration into the cached subscription and never stored; the subscription id and every delivery's ref_id are declared:NAME. A declared webhook signs byte-identically with no row, no cipher and no CryptoProvider (webhook-secret-at-rest.test.ts, the two new pins). An invalid declaration is skipped with one warning per name; the first registration of a name holds it, as the seeder's upsert did.
  3. The organization dimension — right, and it is what the plan's constraint meant. Measured on main: the 18.0 mirror is stamped with the Default Organization (fixture row org_mvf8cxynmka5sb9b) and its two deliveries with the same. Step 0's constraint "serve in the Default Organization under single" is met by deploymentWide (auto-enqueuer.ts:581-598, :1204, :1351, :1432): under a posture with no wall the declaration receives every organization's events and each delivery is stamped with the EVENT's organization, which under single is the Default Organization (D9). Under a wall the 2026-09-07 org-less refusal stands, said once; an absent or unreadable tenancy service reads as walled (postureBuildsNoWall, webhook-outbox-plugin.ts:488). Pinned three ways, posture read included.
  4. The 18.0 dual read — right on all three constraints. classifyDeclaredNameHolder (declared-webhooks.ts:253) reproduces the 18.0 seeder's own treatment of a row holding a declared name: a pristine package mirror is skipped in doRefresh (auto-enqueuer.ts:485, mirrorIds) and the declaration answers — one delivery, never two, following the declaration's URL and switch, exactly as the seeder's rewrite on every boot made it do; a customized: true row or an admin row answers as itself and the declaration is withheld with one warn per name per process carrying the remedy, so an administrator's active: false keeps holding; a mirror whose declaration is gone keeps dispatching. No code path moves or deletes an 18.0 row: the fixture's seven dual-read pins assert engine.writes empty, and the only remaining boot write is the pre-existing cleartext sweep. The committed harness fixture's row is customized: 1, active: 1 under a declaration shipped isActive: false — the "row" branch, pinned by "a seeded row an administrator pointed elsewhere (customized) answers as itself"; the harness itself is unmeasured on this head (③ item 2).
  5. The redelivery veto — right. A declared: ref is judged by the declaration (not declared any more → refused; switched off → refused); a row id is judged by the row; a pristine mirror still declared is judged by its declaration, a customized one by the row (redeliver-guard.ts:124-126, :143; judgeDeclared :200). A lookup that throws propagates: "could not check" never reads as "allowed". Four pins, the pristine-mirror replay included.
  6. The regime-C switch — right, in both directions. active: switched.get(name) ?? webhook.isActive !== false (declared-webhooks.ts:173; the ledger read readWebhookActivation :217): a ledger row of type webhook overrides the declared isActive either way, another type's row of the same name does nothing, a composition without the ledger object issues no read, and a failed read keeps the declared default for that pass with a warn — the sharing-rule reader's conservative rule. A ledger write refreshes the cache in the same request through a third realtime subscription on sys_metadata_activation (auto-enqueuer.ts:382-384, handleLedgerEvent :1491); plugin-sharing, runtime: sharing_rule under regime C, so every same-name row is refused and declared rules are switched through the activation ledger (ruling A on #24378) #24428 used a data hook, but this plugin already refreshes its sys_webhook cache through realtime, dispatch needs realtime anyway, and the shutdown pin now counts three subscriptions released. Five pins.
  7. The door — right, with sharing_rule preserved. webhook is admitted beside sharing_rule through one DECLARATION_LOOKUPS table (catalog-activation.ts:91, :144-174); the two tiers are unchanged (manage_metadata, then the ADR-0126 §5 posture rule — the platform operator under group/isolated, WEBHOOK_ACTIVATION_SUBJECT wording in activation-gate.ts:201); 404 for an undeclared name, 503 when no reader is bound or the read fails (a reader that throws before its promise is a failed read, :295), the package recorded, a package-less declaration admitted. Seven pins; the six sharing_rule pins stay green with the generalised messages.
  8. R1–R4 — right; nothing walled-only or posture-dependent beyond the rulings. R1 measured on a fresh showcase (0 rows; door 200/404; one declared: delivery; switched off, none). R3 untouched. R4: serve.ts untouched; the only posture-dependent branch is deploymentWide, taken only where the posture builds no wall. The walled shape of a declared webhook is deferred to step 4 (③ item 6).
  9. Complete feature (6270035914) — right for the API contract. A new install writes the new shape and dispatches it; the switch works through the door; the 18.0 shape is still read. Nothing is dormant. The console counterpart is owed separately (③ item 7).
  10. The liveness ledger — the measured verdict is right; its consequence is not carried through the tree (blocking, see verdict). packages/spec/liveness/webhook.json re-anchors nine props to the registry read and flips label and description to dead (their only reader was the seeded Setup row). dead is a ruled verdict that warns an author with no opt-in (lint-liveness-properties.ts:169-176, :261-265: "has no runtime effect (liveness: dead) … Remove it"). The PR's own tree contradicts that: packages/lint/src/lint-liveness-properties.test.ts:258-271 pins ZERO findings for an authored description against the REAL webhook.json, and examples/app-showcase/src/automation/webhooks/index.ts still authors label and description. This is what reddened Test Core (2/6) (the shard's annotation: packages/lint pnpm run test exited 1; main is green on that shard at ec16dbe2 and 5a871e4e). The dev's 178 gate commands do not include the lint package's unit tests, and check:liveness judges only the ledger's internal consistency.
  11. Spec, docs and ledgers — right. webhook.zod.ts and the retired-keys entry change doc comments only (no schema change; WebhookSchema still admits the envelope keys the registry stamps). The tenant-audit census was regenerated with the tool (231→229: the seeder's two writes); the four scripts/* baselines are re-keyed for the renamed test or shrunk for the deleted source; the showcase docblock names the door. Left unchanged, rightly: organization-ownership-inventory.ts:548-555 (turning declared into a mirror-deletion departure is ceremony behaviour), sys-metadata-activation.object.ts, serve.ts, every test(qa): the 18.x line's upgrade harness (an 18.0 database boots on every later main with its data) and the measured step plan #24522 harness file, docs/adr/**, content/docs/releases/.

② Semver level

Clause-②: yes (narrowing) on the PR body and in .changeset/24529-declared-webhooks-from-registry.md — the right arm. yes: the diff adds accepted surface on published payloads (:type = webhook on POST /api/v1/security/_activation/:type/:name, metadata_type = webhook rows in sys_metadata_activation, ref_id = declared:NAME on sys_http_delivery). (narrowing): the editable Setup row for a declared webhook, the customized: true path, is gone — BREAKING, shipped as minor under the launch-window convention (check-changeset-no-major refuses a major). Levels: @objectstack/plugin-webhooks minor and @objectstack/runtime minor (both move published source), @objectstack/spec patch (doc comments and the shipped liveness ledger; no schema key added, removed or renamed). The ADR-0087 marker is not-required (no-migration-prescription), the disposition #24428 carried and the right one here: no authorable key, export, option or stored shape is added, removed or renamed; the 18.0 rows stay in place and keep reading. The FROM→TO is stated (editable seeded row → the activation door for the switch plus a copy under a new name for a change; managed_by: 'package' row → none). No public export is removed: src/index.ts is untouched, and bootstrapDeclaredWebhooks, headersPatch (webhook-headers.ts) and canResolveSecrets (webhook-secret.ts) were module-level only. Check Changeset is green on this head. If the fix for the blocking item retires label/description from WebhookSchema rather than from the showcase alone, that is a schema face and a spec minor with a tombstone — not this PR.

③ Boundary flags

  1. Gate state on this head (40 of 41 check-runs concluded at write): Test Core (2/6) FAILURE — packages/lint tests, PR-caused (① item 10); Test Core (3/6) success (it was still running at dispatch); the aggregate Test Core in progress; everything else green; Dogfood Regression Gate (n/3), Dogfood Verify CLI, Console Pin Gate and Temporal Conformance skipped by ci.yml:2026 on pull_request. The shard log tail (job 118128990150) shows the cli package green; the lint failure is above the 400-line tail and is read here from the annotation plus the test source.
  2. Merge gate (6267391172 item 3) not yet met on this head. The harness merged at ec16dbe2 (PR test(dogfood): the 18.x upgrade harness — an 18.0 database boots on HEAD with its data (step 0) #24552, 14:45Z); it is not an ancestor of 6b3042a8 (merge-base 5b14b7f3, nine commits behind origin/main), so the head does not contain upgrade-18x-harness.dogfood.test.ts and R2 is unmeasured here. The dogfood job never runs on pull_request, and the opt-in lane (PR ci: an opt-in label lane runs the 18.x upgrade harness on a line PR's merge preview (step 0 wiring) #24554) is open and Tier H — so "harness green" can only come from the lane or a run the seat makes. The next head must merge origin/main and show the harness green before its landing record. Step 0's pr_cut asked the step-1 PR to add one harness case (the customized mirror dispatches exactly once); the claim's surface forbids touching test(qa): the 18.x line's upgrade harness (an 18.0 database boots on every later main with its data) and the measured step plan #24522's harness files, and the dev followed the claim — the equivalent shape is pinned in declared-webhooks.test.ts only.
  3. ceremony_owes (6270150096) — complete for the tables, two precisions owed before step 5. Covered: pristine-mirror deletion and the mirror-deletion departure on inventory :548-555; customized-row settlement; sys_http_delivery re-source; orphaned sys_secret ciphers; the customized / managed_by columns; the step-7 code (dual read, provenance stamp, sweep's package half). Owed: (a) owes[1] settles every customized row as "a copy under a new name plus the declaration switched off", but step 0's ceremony_must_later distinguishes an active-only customization (an activation row, no clone) from any other edit (a clone plus the switch) — the committed fixture's row (active: 1 under isActive: false, URL unchanged) is the active-only case; (b) owes[3] re-sources deleted mirrors' tails to declared:NAME, which is right for pristine mirrors only — a customized mirror converted to a clone must have its tail re-sourced to the clone's row id. The ceremony's anchor walk (organization-ownership-plan.ts:1171-1201) touches only organization_id IS NULL rows, and a declared: delivery is always stamped, so the parent('ref_id','sys_webhook') anchor owes nothing.
  4. The 409 name guard — not required for this step; required before step 4. Answer to the open question: under single the only actor who can author a sys_webhook row under a declared name is the same administrator the door serves, the withhold is warned once with the remedy, and no 18.0 data is at risk, so the omission does not block step 1. But heldByRow (auto-enqueuer.ts:575-590) is deployment-global by name: once a wall exists (step 4), one organization's same-name row would silence a deployment-wide declaration for every organization — the cross-organization effect bindDeclaredRuleNameGuard (sharing-rule-service.ts:237) exists to refuse, and part of "the shape [Decision] Declared sharing rules: what switching off a vendor's rule means once they are read from the registry #24378 ruled A" that 6259141885 cites. Carry it to the step-4 card (feat(objectql,cli)!: 18.x step 4: the walled lift (8b walled half plus the walled part of #24489) #24580) or its own card: refuse an insert of a declared name and a rename onto one with 409 RESOURCE_CONFLICT, system writes and the existing 18.0 rows untouched — compatible with expand-first. "Expand-first adds no new refusal" is not what 6266750393 item 2 says; it governs data moves and deletes.
  5. Prose/code mismatch on platform rows. The PR body and owes[0] say pristine mirrors are managed_by in package/platform; classifyDeclaredNameHolder treats only package as a mirror, while the 18.0 seeder overwrote every row that was neither admin nor customized. Harmless for seeder-written rows (always package); align the predicate or the prose at step 5/7.
  6. Walled posture is deferred, rightly, but step 4 must decide it. Under a wall a declared webhook is org-less and refused with one warning — unreachable while R4 refuses walled boots. The step-4 card must choose the walled shape (deployment-wide across the wall, or per-organization) consistent with 6259141885's rejection of Q2 C (silent loss under a wall).
  7. Console counterpart owed. Setup → Integrations → Webhooks no longer lists declared webhooks and nothing in the console switches one; the API door is the only switch. The objectui "Disable and Clone" surface (as ci(devx): delete the stale cost note on the dispatch-gates self-test step #12982 for sharing rules) is a card of its own. The enforce-or-remove debt on label / description (ADR-0049) is that card's input.
  8. Checklist text contradicts the measured behaviour. docs/qa/platform-checklist/areas/integration-system.json revision 8 still says the declaration "is read ORG-LESS … so it delivers nothing on the org-walled showcase" (fixtures.requires[0], steps[1]); commit 723af9722 made it deployment-wide under single, and the PR's own fresh showcase boot recorded one declared: delivery stamped with the Default Organization. Fix the two strings; AutoEnqueuer.admitsOrganization's docblock is stale the same way (the dev noted it).
  9. Out-of-surface edits — accepted (packages/spec liveness and comments, packages/qa/dogfood webhook proof and fixture, the checklist item, content/docs/automation/webhooks.mdx, the regenerated census, the showcase docblock, four scripts/* ledgers): each is a consequence of deleting the seeder; none is a test(qa): the 18.x line's upgrade harness (an 18.0 database boots on every later main with its data) and the measured step plan #24522 harness file, an ADR or a releases page.
  10. Size. 1,869 + 1,129 = 2,998 lines against the 3,000 cap (6270035914 item 2); Check PR Size green. The fix for the blocking item must stay within two lines net, or the landing becomes Tier H.

Blocking item, with its fix:

  • B1 — the dead flip of label / description is not carried through the tree, and the head is red on it. packages/spec/liveness/webhook.json (label, description: status: "dead") vs packages/lint/src/lint-liveness-properties.test.ts:258-271 (pins zero findings for an authored description against the real ledger) and examples/app-showcase/src/automation/webhooks/index.ts:21,27 (authors label and description, which objectstack lint now tells its author to remove). Fix, keeping the measured verdict: update the regression guard at lint-liveness-properties.test.ts:258-271 to expect the liveness-dead-property finding for description (and label, if authored) with the ledger's note as its hint, and drop label and description from the showcase declaration so the shipped example does not warn; then run pnpm --filter @objectstack/lint test and let Test Core (2/6) go green. The alternative — grading the two rows planned — is honest only once a card commits to the Setup surface that would read them, so it is not the fix for this head.

Implemented-by: claude/issue-24529-8b0g-webhooks-registry
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: FAIL

…authors; checklist and admitsOrganization docblock say a declared webhook is deployment-wide under single

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…the fixture's customized declared-webhook mirror

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
…n fail on a double delivery

Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat re-review · CONTRACT_REVIEW_TIER (B1 only) · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-19T17:04Z

Head 9bce0aaf. This supersedes the FAIL of 6271423990 on its single blocking item. Every other judgment in that record stands.

  • B1 resolved.
    • The two dead liveness rows (label, description) gain an authorHint.
    • The guard at lint-liveness-properties.test.ts now expects exactly those two liveness-dead-property findings, with that hint. A new case pins silence for a declaration that authors only what is delivered on.
    • The showcase declaration drops both keys.
    • Lint: 148 files, 6,924 passed (addendum 6271777060).
  • The merge gate is met. origin/main is merged (5a871e4e, which carries the harness). The harness passes 6/6, including the new case 2b: the 18.0 customized declared webhook dispatches exactly once on a task change. Ablation turned 2b red, and the restore was proven.
  • CI on 9bce0aaf: 36 success, 5 skipped, 0 failed.
  • The two checklist strings and the admitsOrganization docblock were fixed in the same push.

VERDICT: PASS.

Tier H by size: +1960/−1146 = 3,106 changed lines, over the 3,000 threshold. The seat sets it ready without auto-merge. The maintainer approves and merges. It is not governed; the size alone puts it in Tier H.

Carried to #24580 (6271514301) and #24581 (6271949316): the declared-name 409 guard and the walled shape, both before step 4, and the two ceremony precisions.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 19, 2026 17:05
…0g-webhooks-registry

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…0g-webhooks-registry

# Conflicts:
#	content/docs/permissions/tenant-audit-census.mdx
#	docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit 191e043 Oct 19, 2026
41 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-24529-8b0g-webhooks-registry branch October 19, 2026 22:51
os-steve pushed a commit that referenced this pull request Oct 19, 2026
#24588 (step 1) has landed. The drop refusal, os dev's way-on line and their
pins from #24635 said "18.0 does not upgrade; keep a v17 database on 17.x":
with the ceremony open they name its steps again, and the refusing class and
its pin stay deleted.

Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(plugin-webhooks): 18.x step 1 (8b-0g′): declared webhooks are read from the registry, the seeder retires, expand-first

3 participants