Skip to content

chore(plugin-webhooks): declare sys_webhook's data-API exposure explicitly — and record that it narrows nothing (#9756) - #9927

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-9756-sys-webhook-enable-block
Aug 19, 2026
Merged

chore(plugin-webhooks): declare sys_webhook's data-API exposure explicitly — and record that it narrows nothing (#9756)#9927
os-warren merged 2 commits into
mainfrom
claude/issue-9756-sys-webhook-enable-block

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #9756

⚠️Deliberately Part of, not a closing reference.#9756 asked for a read-surface narrowing. The census it mandated shows that narrowing does not exist as an enable.apiMethods change, so what landed here is the other half — the posture written down — and the card should stay open for triage to re-grade with the measurement in hand. Merging this must not close it.

The census — the deliverable that authorizes the edit

Taken on origin/main @ b0300556d before anything was edited. Each row is a consumer that reaches sys_webhook, and which API it arrives through is the load-bearing column: enable.apiMethods gates only REST (rest-server.ts), the callData dispatcher (runtime/api-exposure.ts) and the MCP data bridge (mcp/stdio-data-bridge.ts). ObjectQL itself never consults it.

#consumerfile:linereaches the rows throughgated?needs
1Setup/Studio console — nav entry nav_webhookspackages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:125REST /api/v1/data/sys_webhooklist
2Console list views (active, inactive, by_object, all_webhooks)sys-webhook.object.ts:58-98REST listlist
3Console record detailsys-webhook.object.ts:51 (userActions)REST getget
4Console create / edit / delete — this object is an admin authoring surfacesys-webhook.object.ts:51 (userActions: { create, edit, delete })REST write routescreateupdatedelete
5Operator predicate write — "deactivate every webhook on an object"auto-enqueuer.ts:952-963 (handleSelfHealEvent, a data.records.* branch built for exactly this gesture, #4639)REST updateMany/deleteManyrest-server.ts:10564,10619bulk
6AutoEnqueuer subscription-cache refresh — the card's cited defn.headers readerauto-enqueuer.ts:320 (engine.find)ObjectQL directly
7bootstrapDeclaredWebhooks — materializes code-declared webhooksbootstrap-declared-webhooks.ts:165,201,225 (engine.find/update/insert, SYSTEM_CTX)ObjectQL directly
8stampWebhookProvenancewebhook-provenance.ts:59 (engine.find)ObjectQL directly
9redeliver-guardredeliver-guard.ts:82 (engine.findOne)ObjectQL directly
10migrateLegacyWebhookSecrets boot sweepmigrate-webhook-secrets.ts:91,115ObjectQL directly
11headers_secret write-shape gatewebhook-headers-gate.ts (lifecycle hooks)ObjectQL directly
12dogfood materialization testpackages/qa/dogfood/test/webhook-materialization.dogfood.test.ts:50 (engine.find)ObjectQL directly

Escalation fork (triage): not triggered. No SDK caller, example app, objectui component or any other consumer reads sys_webhook through the data API — the only gated reader is the admin/operator console. objectui names the object in two tests only, both as a fixture for the managedBy: 'config' bucket, neither issuing a read.

⇒ every one of the six primitives is required by a real, measured consumer.

What landed

enable: {apiMethods: ['get','list','create','update','delete','bulk'],}

The condition #9756 measured is real and was re-verified at the branch point: the object declared no enable block, so it held the full default data API — not because anyone judged that correct, but because three cards each pointed at the next one. This is that decision, written down.

⛔ The finding: this narrows nothing, and apiMethods cannot narrow it

resolveEffectiveApiMethods (packages/spec/src/data/api-derivation.ts) seeds its unrestricted branch with the very same API_PRIMITIVES set, so all six primitives resolve to the operation closure the absent block already produced. Measured consequences:

  • the serialized effective set — the 405 allowed array and /me/permissionsapiOperations — is byte-identical;
  • no REST route and no callData action reaches an operation whose answer differs (history/restore/purge are the only operations whose gate answer moves, and no route gates on any of them);
  • only mode changes, unrestrictedrestricted.

Why no narrowing exists here. The reachable cleartext #9756 is about — url, and a legacy row's un-migrated definition_json.headers (still read and warned about by readLegacyHeaders, auto-enqueuer.ts:579) — is served by get/list, which is exactly what the console requires. Any set that removes them removes the admin surface with it. And bulk, the one primitive that could be dropped without touching reads, gates a deliberately supported operator gesture (row 5) — dropping it would also require an exemption entry in packages/spec's conformance ratchet, outside this card's declared file surface.

The sibling sys_http_delivery holds ['get','list'] because it is engine-owned — written only by SqlHttpOutbox through context-less raw-engine writes, never authored. sys_webhook is a first-class admin authoring surface. That difference is why the sibling's shape could not simply be copied, and it is recorded in the object's docblock so the next reader does not re-derive it.

⚠️The trap this PR is written against:#9756's own detection method is grep -A8 '^\s*enable:' sys-webhook.object.ts, and that grep flips from empty to a hit here while the exposure is unchanged. The docblock, the changeset and the pin below all say so explicitly, so a future survey cannot read the block's presence as an all-clear.

Masking url stays out of scope — #8025 settled that on engineering grounds and that ruling stands; nothing here reopens it.

Pins — src/sys-webhook-api-exposure.test.ts (7 tests)

  • the declared set is exactly the six primitives, and only primitives (legacy verbs are derived, never authored — P2:ApiMethod 枚举收缩至 6 原语(breaking,独立版本) #3543);
  • all 10 gated census operations are admitted, behind an anti-vacuity floor (CENSUS.length >= 10);
  • the no-narrowing equality: effectiveOperationsArray(declared) equals effectiveOperationsArray(absent) and the primitive sets match, with mode asserted as the one and only difference — so a later change that really does move the surface has to confront this test;
  • registration keeps every declared write verb: checkManagedApiMethodAffordances(SysWebhook) is empty, so reconcileManagedApiMethods strips nothing at boot (closing userActions.delete would otherwise take delete off the API with only a console.warn);
  • rejection pins per ADR-0112 — asserted on the discriminant, never on a bare throw: a counterfactual ['get','list'] block answers 'method-not-allowed' for create/update/delete/bulk, with reads staying open as the control that makes it an oracle rather than a helper that refuses everything; { apiEnabled: false } answers 'api-disabled'. Both envelope codes are pinned as ADR-0112-registered vocabulary. The { status, code } envelopes themselves belong to @objectstack/rest and the MCP bridge and are pinned there — this package does not grow a dependency to assert someone else's envelope.

Verification — all at d9653e87b, the final commit

whatresult
pnpm --filter @objectstack/plugin-webhooks testTest Files 9 passed (9) · Tests 119 passed (119)
pnpm --filter @objectstack/plugin-webhooks typecheckexit 0 (tsc --noEmit echoed — not a zero-match pass)
check:slot-lookup, check:test-source-alias, check:type-source-resolutionexit 0
check:i18nplugins/plugin-webhooks in sync (4 bundle(s)) — first run reported PREREQUISITE NOT MET — the workspace CLI is not built and checked nothing; re-run green after building the CLI
check:type-check-debt (--re-measure)33 ledger entr(ies) re-measured … none above its recorded number — needed the full workspace closure built; it refuses loudly rather than measuring a different world
check:nul-bytes, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:type-check-coverageexit 0
check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changesetexit 0
scripts/docs-audit/check-affected-docs.mjsexit 0

Gates re-derived against the actual diff with node scripts/pm/dispatch-gates.mjs (no hand-built path list); it added check:i18n, the changeset family and the convention-triggered ratchets on top of the dispatched list. Every exit code was captured by redirecting first, never through a pipe.

Ablation — direction predicted before running, and it is not "everything goes red": removing the enable block gives 2 failed | 5 passed, signatures expected undefined to deeply equal [ 'get', 'list', 'create', …(3) ] and expected 'unrestricted' to be 'restricted'. The five that stay green — all 10 census admissions, registration survival, and the reachable-cleartext assertions — are the mechanical proof of the finding: every consumer-facing property is indifferent to whether this block exists. No rebuild leg was needed and none could hide a false green: the subject is imported relatively (./sys-webhook.object.js), and packages/plugins/plugin-webhooks/dist does not exist at all while the suite runs, so resolution is provably through source. Restored byte-identically — git hash-objecte2c6e61fdd9c4ceb33eed17c473ecde959e0cf3d before and after, git status --porcelain clean (no staged/unstaged split) — and the restore leg re-verified green, 7 passed.

Changeset

patch on @objectstack/plugin-webhooks. Rule applied: the package is published (17.0.0, not private) and its shipped object metadata changed ⇒ a changeset is owed. It is not declared-breaking — nothing authorable is removed or renamed and the effective closure is unchanged — so no ADR-0087 disposition marker is required, and check-adr-0087-registration agrees. The changeset body leads with the fact that the effective surface is unchanged, so an upgrading consumer is not told a narrowing happened.


Generated by Claude Code

…itly (#9756)
Three cards (#7799, #7986, #8025 option 2) each observed that `sys_webhook`
declared no `enable` block and named narrowing its read surface as the next
step; none owned the line, so the full default API held by omission rather
than by judgement.
The census #9756 mandated (measured before writing anything) derives all six
primitives: the Setup/Studio console needs get/list/create/update/delete
(`userActions` opens all three writes, four list views, `nav_webhooks`), a
predicate deactivate/delete over sys_webhook is a supported operator gesture
(#4639, with a self-heal branch built for it) and gates on `bulk`, and every
other consumer — AutoEnqueuer, bootstrapDeclaredWebhooks, provenance stamp,
redeliver-guard, the secret sweep — reaches the rows through `engine.*`,
which never consults `enable.apiMethods`.
So the declaration records the posture; it does NOT narrow the surface. The
six primitives resolve to the closure the absent block already produced, and
that equality is pinned rather than left for a later reader to rediscover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…9756)
Published package (plugin-webhooks 17.0.0, not private) whose shipped object
metadata changed ⇒ patch changeset. Not declared-breaking: nothing authorable
is removed or renamed, and the effective operation closure is unchanged, so no
ADR-0087 disposition marker is required.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-webhooks, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx(via SysWebhook (symbol), /api/v1/data/sys_webhook (route))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 3 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 d7c42405517bd7ffbe46d570fc2848b4b1cc597epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 335a16b1e145b9988d29742ca6d76864b36a5dfe — the merge of head d9653e87bf87ba927c3e12dbe9008526b7fe0fbb into base d7c42405517bd7ffbe46d570fc2848b4b1cc597e, 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 335a16b1e145b9988d29742ca6d76864b36a5dfe && git checkout 335a16b1e145b9988d29742ca6d76864b36a5dfe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d7c42405517bd7ffbe46d570fc2848b4b1cc597e d9653e87bf87ba927c3e12dbe9008526b7fe0fbb && git checkout -B drift-repro d7c42405517bd7ffbe46d570fc2848b4b1cc597e && git merge --no-ff d9653e87bf87ba927c3e12dbe9008526b7fe0fbb
node scripts/docs-audit/affected-docs.mjs --json d7c42405517bd7ffbe46d570fc2848b4b1cc597e

⚠️ 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 d7c42405517bd7ffbe46d570fc2848b4b1cc597e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-warren@claude