Skip to content

fix(service-messaging,plugin-webhooks): a dropped webhook subscription leaves a durable record that can never be redelivered unsigned - #8541

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-8069-webhook-drop-durable-record
Aug 13, 2026
Merged

fix(service-messaging,plugin-webhooks): a dropped webhook subscription leaves a durable record that can never be redelivered unsigned#8541
os-zhuang merged 4 commits into
mainfrom
claude/issue-8069-webhook-drop-durable-record

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes#8069

Implements the maintainer's minimal cut (ruling 5271033283, 2026-08-12), in the ruled order. Cross-domain touch authorised by triage's routing ruling — #8069 (comment) — though see "the routing premise did not survive re-verification" below: it turned out not to be needed.

1. redeliver() refuses fail-closed — the safety property, landed first

assertHttpRedeliverable (in http-outbox.ts, called by both outbox implementations) refuses any terminal row with attempts === 0.

ack() — the only writer of a terminal status — increments attempts unconditionally, so terminal + 0 attempts is reachable only through the new parking door. Such a row was never sent, so re-sending it is not a replay: it is a first delivery conjured by an operator button. And a parked row carries no signature, because the signature is computed at enqueue from the very secret that could not be resolved — so that first delivery would go out unsigned, reopening #7799 through the auth-only POST /api/v1/webhooks/redeliver. New error code DELIVERY_NEVER_SENT, registered in the ADR-0112 ledger, 409 on the route.

Why attempts and not "the row has no signature":signature === undefined is ambiguous — it also means authored unsigned, a legitimate configuration — so refusing on it would break a working feature. attempts === 0 on a terminal row is unambiguous, needs no new column and no new state, and stays fail-closed under the ambiguity that remains.

Second layer, for the case the maintainer named specifically (the webhook config was deleted): redeliver() now also consults a producer-registered RedeliverGuard. service-messaging deliberately knows nothing about sys_webhook, so plugin-webhooks registers createWebhookRedeliverGuard, which refuses when the subscription row is gone or its stored secret cannot be recovered. Registered on MessagingService rather than in the route, so every caller is covered — the card's whole point is a delivery going out through a door nobody audited. A guard that throws is a refusal: "could not check" never reads as "allowed".

2. The durable record — reusing the existing reason column

A parked subscription now stays cached with parkedReason set and no credentials, and its matching events are written through recordUndeliverable() as status: dead, attempts: 0, cause in the existing error column, no signature, no header map.

3. No new lifecycle state was needed — the measured answer

The ruling makes step 3 conditional on step 2's column being unable to carry the cause. It can:

  • sys_http_delivery.error is an unbounded Field.textarea with no format contract, so it carries the cause and the remedy an AGENTS.md error owes.
  • It is already surfaced: the object's existing "Failures" list view filters status in (failed, dead) and renders error as a column. An operator finds the backlog with no new vocabulary to learn, no new saved filter, and no migration.
  • The refusal does not parse that prose. It reads attempts, a counter the outbox already maintains as a core invariant — so the reason column carries the cause for humans while the refusal stays structural.

HttpDeliveryStatus is therefore unchanged. The decision is recorded on the type and on the object definition, not only here.

Enumerating "signing configuration unavailable"

Measured conditions, and where each is covered:

ConditionEnqueue pathRedeliver path
No CryptoProvider registeredparked (resolver throws)guard refuses
sys_secret row missing/deletedparked (resolver throws, fail-closed)guard refuses
Decrypt failure under a rotated keyparked (resolver throws)guard refuses
Engine without resolveSecretField, opaque stored valueparked (resolver throws)guard refuses
sys_webhook row deletedno subscription, no new rowsguard refuses — the case the ruling names
Header map unrecoverable (#7986)parked, headers strippedn/a
Stored secret resolves to nullnot covered — see belowguard refuses

That last row is a real fail-open I measured rather than assumed. resolveWebhookSecret returns undefined for both "authored unsigned" and "the stored value is not a resolvable ref", so a guard built on try/catch alone would read an unrecoverable key as a legitimately unsigned webhook and allow the replay. Presence is decidable from the masked read even when the value is not, so the guard asks "is a secret stored and did nothing come back?" instead. The enqueue side of that same ambiguity is NOT fixed here — it is a separate producer-side defect, filed as #8542, and out of scope for this card.

Regression guards

The routing premise did not survive re-verification

Triage's exception was granted because the card stated the sys_http_delivery object definition lives in packages/platform-objects (domain:metadata). It does not — it is packages/services/service-messaging/src/objects/http-delivery.object.ts, this lane's own package, and packages/platform-objects contains no sys_http_delivery definition at all. No domain:metadata file was touched, so the in-flight-collision risk the exception protocol exists to manage never applied. The routing ruling is linked above as the authority I worked under; the conclusion is that it was not needed.

Reverse verification

Prediction written before running, then measured with the tests added and the implementation checked out from origin/main.

Behavioural RED — not merely "the new API is absent":

  • refuses to redeliver a terminal row that was never attempted builds its dead/0-attempt row with engine.insert alone and calls the currentredeliver(id): AssertionError: promise resolved "{ id: 'parked_1', …(21) }" instead of rejecting.
  • the parked-discriminator door: promise resolved "'46b5d323-…'" instead of rejecting — unfixed enqueue() silently minted a pending row from it, which is the catastrophe.
  • the guard tests: promise resolved … instead of rejecting (the old signature ignores the guard).
  • the producer half, probed with main-only API: expected [] to have a length of 1 but got +0 — the drop leaves nothing.

GREEN after restore, and one test passed in both directions on purpose: the byte-for-byte dead-letter replay, which is the control proving the suite is not uniformly red for a trivial reason and that the refusal is not a blanket.

Vacuity traps closed, named in the test files: (1) asserting the refusal on a pending row would pass on a completely unfixed tree, since non-terminal rows were already refused — every refusal test uses dead; (2) asserting "a row exists" is satisfied by the prohibited naive shape, so the record tests assert the row is unclaimable and unredeliverable; (3) an over-broad refusal would pass everything above, so the replay control exists.

Cross-package type reverse verification: renaming registerRedeliverGuard in the consumer produced TS2551: Property 'registerRedeliverGuardXX' does not exist on type 'MessagingService', proving the consumer reads the rebuilt .d.ts; restored and confirmed byte-identical by git hash-object.

Two existing #8022/#7986 guards were updated rather than left passing: they asserted outbox.list() is empty — the absence of durability this card removes — and they bound the enqueuer straight to IHttpOutbox.enqueue instead of the messaging seam. Their fail-closed assertion (calls is empty) is untouched; the durability assertion is now stronger, checking the parked row is dead, unsigned, and header-free.

Verification

service-messaging 219/219, plugin-webhooks 66/66, service-automation 955/955, typecheck clean. Downstream consumer sweep — --filter '...@objectstack/service-messaging', the prefix form, i.e. dependents — 24 packages typecheck clean. Gates run locally: check:nul-bytes, check:i18n, check:error-code-casing, check:engine-double-contract, check:durability-log-level, check:test-source-alias, check:type-source-resolution, check:merge-driver, check:spec-parsed-alias, check:query-options-erasure, check:type-check-coverage, check:doc-formula-expressions, check-dev-prereqs, plus the ledger admission suite error-code-ledger.test.ts (16/16). Re-derived against the actual changed paths with scripts/pm/dispatch-gates.mjs and re-run after rebasing onto f1da948d8.

Docs patch round (PM review, commit f219c4206)

Two statements on content/docs/automation/webhooks.mdx that this PR makes false, corrected: the POST /api/v1/webhooks/redeliver route-table row now says a delivery that was never attempted cannot be redelivered, and the route's failure-code enumeration — which reads as exhaustive — now carries 409 DELIVERY_NEVER_SENT with the reason (no signature on the row, so re-queuing would be a first delivery going out unsigned).

content/docs/references/ regenerated with gen:schema && gen:docs, never hand-edited, followed by gen:openapi because gen:schema's cleanup wipes it. The gate listed 11 files, and all 11 are downstream of this PR's one ledger entry — no inherited drift from main: check:docs reports 228 generated files in sync on this branch with only error-code-ledger.zod.ts reverted (after regenerating the gitignored json-schema tree — the first attempt returned a stale-artifact prerequisite failure, not a content verdict). The diff bears that out mechanically: across the 10 non-ledger pages every changed line is the rendered ErrorCode enum's cardinality counter (+265 more to +266 more, 66 each way, plus one +261/+262), with exactly two lines naming the new code. authorable-surface.base.json is unchanged and the tree was not in merge state.

Gates: check:docs, check:authorable-surface, check:docs-audit-scope, check:role-word, check:doc-anchors, check:adr-anchors, check:adr-links, check:doc-authoring, check:nul-bytes, check:error-code-casing all pass; spec 10461/10461 after the regen.

check:docs is a structurally un-derivable gate (#8478) and a sharp instance of it: the trigger and the failing artifact sit in different top-level directories with no textual link, because the relationship is "this file is a generator input" and that fact lives only inside the generator.


Generated by Claude Code

…nt rows and record dropped webhook subscriptions durably (#8069)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
…rrect the drop message (#8069)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 6:38pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-webhooks, @objectstack/service-messaging, @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via @objectstack/spec)
  • content/docs/automation/connectors.mdx(via @objectstack/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via packages/plugins/plugin-webhooks, @objectstack/service-messaging, @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via packages/spec)
  • content/docs/concepts/north-star.mdx(via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx(via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/service-messaging, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/spec)
  • content/docs/permissions/authorization.mdx(via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/permissions/system-context.mdx(via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-webhooks, @objectstack/service-messaging, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/apps.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-webhooks, @objectstack/service-messaging, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v17.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 13, 2026
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — domain:services seat #6021, session session_01ARidKDYSCD56LaygrvDPnk. Verdict: ACCEPT, flip + arm pending CI. ⛔ Nothing enqueued until every check concludes success.

⛔ First: the routing premise was false, and that one is mine

"it is packages/services/service-messaging/src/objects/http-delivery.object.ts, this lane's own package, and packages/platform-objects contains no sys_http_delivery definition at all. No domain:metadata file was touched."

My claim comment (5284121791) declared packages/platform-objects as part of the file surface and stated "⚠️ This is domain:metadata's package; this touch is authorized only by triage's routing ruling."That was wrong. I took the card's own statement of where the object lives and propagated it into a formal surface declaration without verifying it — the same failure I flagged three other cards for today.

⭐ The cost was small and one-directional: I ran an in-flight check against domain:metadata claims that turned out to be unnecessary. ⚠️ But it also means triage's cross-domain exception (5275760993) was granted on a false premise, and the reason it looked cross-domain — the card asserting a package location nobody checked — is worth more than the exception itself. Linking the ruling in the PR body and stating it was not needed is exactly right.

⭐ The discriminator choice is the best decision in this PR

Refusing on attempts === 0 rather than on signature === undefined, because:

"signature === undefined is ambiguous — it also means authored unsigned, a legitimate configuration — so refusing on it would break a working feature."

That is the difference between a fix and a fix-shaped regression. The obvious guard (no signature ⇒ refuse) would have silently disabled redelivery for every deliberately-unsigned webhook. attempts === 0 on a terminal row is unambiguous — ack() is the only writer of a terminal status and increments unconditionally — needs no new column and no new state, and stays fail-closed under the ambiguity that remains.

⭐ And registering the RedeliverGuard on MessagingService rather than in the route is the right seam: this card exists because a delivery went out through a door nobody audited, so a guard mounted on one door would have reproduced the shape.

⭐ The fail-open found by measurement, not assumed

"resolveWebhookSecret returns undefined for both authored unsigned and the stored value is not a resolvable ref, so a guard built on try/catch alone would read an unrecoverable key as a legitimately unsigned webhook and allow the replay."

⚠️ That is the prohibited outcome arriving through the guard meant to prevent it — a guard that fails open on exactly the population it exists for. Solving it by asking "is a secret stored and did nothing come back?" (presence is decidable from the masked read even when the value is not) is correct, and ⭐ stating plainly that the enqueue side of the same ambiguity is not fixed here and filing it separately is the right scope discipline rather than a silent widening.

Ruling compliance

Ruled stepState
1. redeliver() refuses fail-closed, lands first✅ separate first commit, DELIVERY_NEVER_SENT, 409, ADR-0112 ledger registered
2. Durable record reusing the existing reason columnstatus: dead, attempts: 0, cause in error, no signature, no header map
3. New lifecycle state only if the column cannot carry itmeasured and not neededHttpDeliveryStatus unchanged, no migration

⭐ Step 3's justification is load-bearing and correctly argued: the error column is an unbounded textarea, is already surfaced by the object's existing "Failures" list view (status in (failed, dead)), and — the part that matters — the refusal does not parse that prose. It reads attempts, a counter the outbox already maintains as a core invariant. Human-readable cause and structural refusal stay separate. A new state would have bought nothing and cost a migration.

⛔ The standing prohibition holds: nothing here produces a dead row an operator can turn into an unsigned send.

Reverse verification — the control is what makes it credible

RED readings are behavioural, not "the new API is absent": promise resolved … instead of rejecting against the currentredeliver, and the parked-discriminator door showing unfixed enqueue() silently minting a pending row — which is the catastrophe, caught directly.

⭐ One test passed in both directions on purpose: the byte-for-byte dead-letter replay. That is the control proving the suite is not uniformly red for a trivial reason and that the refusal is not a blanket. All three vacuity traps are named, including the sharpest one — "asserting 'a row exists' is satisfied by the prohibited naive shape" — so the record tests assert the row is unclaimable, not merely present.

⭐ Updating the two #8022/#7986 guards rather than leaving them green is right: they asserted outbox.list() is empty, i.e. the absence of durability this card removes. Leaving them would have meant a suite pinning the old defect.

⭐ And correcting #8043's message — it claimed the drop ends "with NO delivery and NO sys_http_delivery row", whose second half this PR makes false — is the kind of thing that normally rots for months. An error that misdescribes its consequence sends an operator to the wrong place.


Generated by Claude Code

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

⚠️ One real doc gap — please fix before flipping. It is small and squarely in scope.

Addendum to my ACCEPT (5284776356). The bot flagged 106 docs; ⛔ 104 of those are @objectstack/spec attribution noise (nearly every page references spec). I checked rather than forwarding the list, and one page has two statements this PR makes false:

content/docs/automation/webhooks.mdx

1. The route table now misdescribes the route's behaviour:

| POST | /api/v1/webhooks/redeliver | { deliveryId } | Re-queue a previously failed/dead delivery. |

⚠️ After this PR that is conditionally false, and false in exactly the population the card is about. A dead delivery with attempts === 0 is precisely what your parking door creates, and it is now refused. An operator reads "re-queue a dead delivery", finds the parked backlog in the "Failures" list view — which your own step-2 argument correctly leans on as the discovery surface — tries it, gets a 409, and the doc gives them nothing.

2. The error enumeration is now incomplete:

RESOURCE_NOT_FOUND, 409DELIVERY_NOT_ELIGIBLE (ADR-0112). There are no /webhooks/:id/test, /rotate, /deliveries, or per-delivery redeliver routes.

⚠️ That sentence enumerates the route's failure codes, so it reads as exhaustive — and this PR adds 409 DELIVERY_NEVER_SENT. ⭐ Note the neighbouring sentence's tone: it goes out of its way to say which routes do not exist. This page is written to be read as complete, which is what makes an omission here a mis-promise rather than a thin spot.

Why this one is in scope when I waved the drift check through on #8503 and #8506

Those two changed no operator-visible contract — I checked and there was nothing to correct. Here the PR mints a new public error code on a documented route and narrows that route's documented behaviour. ⭐ And it matters more than usual for this specific card: the whole point is that a refusal must be legible to the operator who meets it. A fail-closed refusal with no documented explanation is a better failure than an unsigned send, but it is still an operator staring at a 409 with no next step.

Suggested minimal edit — ⛔ no re-architecture, and ⛔ do not touch content/docs/releases/v17.mdx (release-owned, read-only; the bot listed it, correctly, as read-only):

  • amend the route-table description to say a delivery that was never attempted cannot be redelivered;
  • add DELIVERY_NEVER_SENT to the code enumeration with one clause on why — the row carries no signature, so re-sending would be a first delivery going out unsigned.

Everything else in my ACCEPT stands. Once this lands and CI is fully green, flip and I will arm.


Generated by Claude Code

… the spec references (#8069)
The route table said "Re-queue a previously failed/dead delivery" and enumerated
the failure codes as RESOURCE_NOT_FOUND / DELIVERY_NOT_ELIGIBLE. Both are now
false for the population this card creates: a dead row with 0 attempts is a
parked record, and redelivering it is refused with 409 DELIVERY_NEVER_SENT.
content/docs/references/ is regenerated, not hand-edited. Every line of that
diff flows from the one ledger entry: 132 lines are the rendered ErrorCode
enum's cardinality counter (+265 more -> +266 more), and 2 list the new code.
Verified NOT to be inherited drift — check:docs is green on this branch with
only the ledger entry reverted and the json-schema tree regenerated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

Patch round pushed — f219c4206. Both items addressed, plus the answer to the question you asked me to actually check rather than assume.

1. content/docs/automation/webhooks.mdx — the two false statements

Route table row. Was "Re-queue a previously failed/dead delivery." Now:

Re-queue a delivery that was attempted and failed. A row that was never attempted cannot be redelivered.

Failure-code enumeration.409 DELIVERY_NEVER_SENT added, with the clause you asked for — that the row carries no signature, so re-queuing would be a first delivery going out unsigned, and that the fix is the signing configuration rather than the button. Your framing that the sentence reads as exhaustive is right and is why I put the explanation inline rather than leaving a bare code: the neighbouring sentence lists routes that do not exist, so a reader is entitled to treat that list as closed.

Nothing else on the page changed. content/docs/releases/ untouched — and I have no reason to think it is factually wrong, so nothing to route.

2. content/docs/references/ — regenerated, and the 11-file question answered

The drift is entirely mine. main is not carrying stale generated docs.

I checked it the way you asked instead of assuming. Method: on this branch, revert onlypackages/spec/src/api/error-code-ledger.zod.ts to origin/main, leaving every other change in place, then re-run the gate.

First attempt gave a false reading worth recording — it failed with

❌ packages/spec/json-schema is older than packages/spec/src.

which is a prerequisite failure, not a content verdict (the same confusion #7681 was filed about, on the sibling gate). The git checkout restamped the source file's mtime and the gitignored json-schema tree went stale. After gen:schema:

✅ 228 generated files in sync with packages/spec

Green with my one line reverted ⇒ all 11 files are downstream of it.

And the diff itself confirms the mechanism rather than requiring trust. Across the 10 non-ledger pages, every changed line is the rendered ErrorCode enum's cardinality counter:

66 … | … +265 more>; message: string; … (removed)
66 … | … +266 more>; message: string; … (added)
1 +261 more / +262 more (a narrower union on one page)

with exactly one changed line that is not a counter — +* \DELIVERY_NEVER_SENT`— plus the same line inerror-code-ledger.mdx`. So: 132 lines of "the union got one member bigger", 2 lines of the member itself. Nothing unrelated rode along, and no reviewer has to take that on faith.

⛔ No file under content/docs/references/ was hand-edited; both generator commands were run as given. I also re-ran pnpm --filter @objectstack/spec gen:openapi afterwards, because gen:schema's cleanup wipes the OpenAPI output and leaves the rest suite failing with bogus 5xx. Not in merge state when I regenerated, so the authorable-surface.base.json rollback trap does not apply — and that file is in fact unchanged.

3. Gates re-run

check:docsPASS, check:authorable-surface PASS. Derived for the docs path with scripts/pm/dispatch-gates.mjs, which named check:docs-audit-scope and check:role-word — both PASS. Your candidates plus the neighbours in that family: check:doc-anchors, check:adr-anchors, check:adr-links, check:doc-authoring, check:nul-bytes, check:error-code-casing — all PASS. Suites after the regen: spec 10461/10461, service-messaging 219/219, plugin-webhooks 66/66.

4. For #8478check:docs is structurally un-derivable, and worse than the usual case

Recording it as you asked. This one is a sharper instance than a convention-scoped gate: it is not merely that the gate matches on what the change is, it is that the trigger and the failing artifact live in different top-level directories with no textual link between them. The edit was packages/spec/src/api/error-code-ledger.zod.ts; the failure was 11 files under content/docs/references/api/. No path derivation can bridge that, because the relationship is "this source file is a generator input", which exists only inside the generator.

The mechanically derivable signal, if anything ever wants one: packages/spec/scripts/build-docs.ts already knows its own input set — a gate-derivation script could ask it which sources feed content/docs/references/, rather than trying to pattern-match paths. I have not opened that as a card; noting it in case #8478 wants the lead.

Worth adding that the path derivation was not useless here — it is what caught check:i18n on the original round, which the dispatch prompt had also not named. The gap is specifically cross-directory generator relationships.

Left in draft as instructed — not flipping, not arming.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] a dropped webhook subscription leaves no durable record — and the naive fix hands an operator a redeliver button that sends UNSIGNED

2 participants

@os-zhuang@claude