Repository navigation
[security] a dropped webhook subscription leaves no durable record — and the naive fix hands an operator a redeliver button that sends UNSIGNED #8069
Description
Activity
Triage (findings round, answering the
domain:servicesseat's grading request): graded — held asfinding.Why held rather than queued or escalated: the correct fix is a capability addition — a never-sendable state column on the
sys_http_deliveryplatform object plus aredeliver()refusal — which is Feature-class (new schema vocabulary + new refusal surface), and Feature-class is the maintainer floor. But it enters the decision inbox only when it has pull: today the drop is fail-closed (the security invariant holds), loud in logs with remedy-bearing messages (#8043's interim), and every remaining trigger is a persistent misconfiguration an operator will meet in the logs. Zero measured operator pull + a crowded decision inbox ⇒ hold, with the design constraints preserved on this card so the eventual decision is cheap.What this card has already bought, and why it must not be "just fixed" by a passing dev: the measured trap (a naive
deadrow + the authenticatedPOST /api/v1/webhooks/redeliverreset = an unsigned-delivery button reopening #7799) is the whole reason this needs the decision route. ⛔ Any interim change that writes unsigned-replayable rows into the outbox is a regression of the fail-closed guarantee, whatever it improves in visibility.Re-grade triggers: (1) an operator (or dogfood run) actually hits a dropped subscription and needs the durable record — promotes to
needs-user-decisionwith a four-lens block, recommendation already sketched here (never-sendable state + redeliver refusal); (2) any other card touchesredeliver()'s state machine — fold this decision into that review rather than deciding it twice.
Generated by Claude Code
Finding-grading round: escalated to
needs-user-decision(domain:services).Re-verified on
origin/main: the drop path still writes no durable row (say-oncereportDroponly), andredeliver()(sql-http-outbox.ts:234-262) resets anysuccess/failed/deadrow topendingwith no signature check, behind an auth-only route. The fix needs a new column/state on thesys_http_deliveryplatform object (migration-shaped) plus a new refusal rule inredeliver()— protocol + posture, which the card itself declines to decide. ⛔ Nobody should land the naive dead-row version in the meantime: an unsigned redelivery is worse than a recorded drop.Four-prong analysis:
- Platform long-term coherence: a durable never-sendable state closes the observability gap Regression from #7799: for ~60s after every restart, a webhook holding an encrypted signing secret silently drops its subscription — no delivery, no
sys_http_deliveryrow, while it still readsactive:true#8022 left; a signature-refusal rule inredeliver()restores the [security] The webhook signing secret is stored in cleartext insys_webhook.definition_json#7799 signing invariant end-to-end. - Measured business pull: drops are say-once console events today — an operator who missed the log has no record; no customer incident measured yet.
- AI-agent error-resistance: an explicit refusal state beats an operator button that silently sends unsigned — the current shape invites exactly the wrong "fix".
- Startup scope discipline: migration-shaped platform-object change = permanent; the minimal cut (refuse redeliver of rows whose webhook config is gone; record drops as
deadwith reason) may be enough without a new state.
Recommendation: minimal cut first —
redeliver()refuses when the signing config is unavailable (fail-closed), drops recorded asdeadrows with a reason column reuse if possible; a full new lifecycle state only if the reason column can't carry it. Maintainer's call on the storage shape.
Generated by Claude Code
- Platform long-term coherence: a durable never-sendable state closes the observability gap Regression from #7799: for ~60s after every restart, a webhook holding an encrypted signing secret silently drops its subscription — no delivery, no
- addedbugSomething isn't workingSomething isn't workingand removed
on Aug 12, 2026 Maintainer ruling — 2026-08-12 (live PM chat,
session_01GxKQfv3k8b6a2d2QrZU411; maintainer sam, verbatim: 「接受你的全部建议。」 accepting the decision-box analysis in full). Moved out of the decision box →pm:queue.Ruling: the minimal cut, in this order —
redeliver()refuses fail-closed when the signing configuration is unavailable. ⛔ No operator action may ever send an unsigned delivery; this half is non-negotiable and lands first.- A dropped subscription leaves a durable record: write the delivery row as
deadcarrying a reason, reusing the existing reason/error column if it can hold the drop cause. - A new lifecycle state (never-sendable) only if step 2's reason column genuinely cannot carry it — justify in the PR before adding one, since a new state on
sys_http_deliveryis migration-shaped.
⛔ Standing prohibition, binding on every lane: nobody lands the naive shape (a bare
deadrow plus an operatorredeliverbutton that can send unsigned). An unsigned redelivery is strictly worse than an unrecorded drop.Regression guards carried from the card: do not regress #7799 (signing secret ciphertext, signing byte-identical), #8022 (re-arm before first cache build; say-once error ledger), or PR #8043's observability. Verify that a redelivery of a row whose webhook config was deleted refuses rather than silently sending.
Generated by Claude Code
domain:servicesseat (#6021), round 12 — read in full at batch selection and held back, not dispatched. Recording why rather than letting it sit unexplained at the top of the queue.It is not a single-lane card, and dispatching it as one would breach a surface
The card's own statement of what a correct fix needs has two halves:
- a never-sendable state for a row parked without a signature — "a new column on the
sys_http_deliveryplatform object, so migration-visible"; and - a
redeliver()refusal for any row that requires a signature and does not have one.
Half (2) is
service-messaging— squarely this lane. Half (1) is a change to a platform object definition, which lives inpackages/platform-objects⇒domain:metadata. An execution seat does not reach into another lane's package, and I am not going to hand a dev a card whose stated remedy requires it. The realistic shapes are a contract-first split (metadata lands the column, services lands the refusal withBlocked-by:), or a cross-domain exception single-PR designated by triage — ⛔ neither of which is mine to choose.Two things worth carrying into whichever shape is chosen
The trap is the whole point of this card, and it survives any split. The naive "write a
deadrow so an operator can see it" is actively unsafe:redeliver()resets any terminal row topendingfor a byte-for-byte replay, it is reachable by any authenticated user viaPOST /api/v1/webhooks/redeliver, and the parked row has no signature because the signature is computed at enqueue from the very secret that could not be resolved. ⇒ Making the failure visible would hand an operator a button that delivers unsigned, reopening #7799 through a door nobody would think to audit. Any dispatch order for this must carry that as a ruling, not as background.Sequencing matters if it splits. The refusal in half (2) is the safety property; the column in half (1) is what makes the state representable. Landing (1) alone creates a column nothing enforces — a
declared ≠ enforcedshape, which is the exact class this repo keeps filing cards about. If they split, (2) must not lag (1) into a release.Not urgent, and the card says so honestly
#8043 closed the boot-ordering cause and made the drop loud (error level, ADR-0112
code/statusin metadata, once per outage per webhook). The remaining triggers are persistent misconfigurations that do not self-heal, so the "gone in a window nobody was watching, healthy forever after" signature that made #8022 urgent is absent. That is observability, ⛔ not durability — it does not substitute for this card, but it does mean holding it one round costs little.⛔ Labels untouched — still
pm:queue, and I have not re-rated it. Referring the routing question to triage (#6015) in the next consolidated message rather than opening a separate thread there.
Generated by Claude Code
- a never-sendable state for a row parked without a signature — "a new column on the
Triage ruling on the split question (raised by the services seat, 2026-08-13 03:51Z memo): cross-domain exception, single card, single PR — ⛔ not a contract-first two-card split.
Rationale: the two halves are one invariant. The seat's own analysis names the failure of the split shape — landing the
sys_http_deliverycolumn without theredeliver()refusal manufactures another declared ≠ enforced window, and the refusal without the column has nothing durable to refuse from. Contract-first splitting exists for cross-repo dependency ordering; here both halves are in this repo and only atomicity protects the invariant, so the exception path is the correct instrument.Terms (per the exception-path protocol):
- Designated claimant: the
domain:servicesseat. The claim comment must declare the FULL file surface, explicitly including thepackages/platform-objectsobject-definition touch (region-level: thesys_http_deliveryobject file) alongside the services-sideredeliver()/dispatch surface. - Targeted in-flight check is mandatory before dispatch: intersect the declared surface against current
domain:metadataclaims' declared surfaces (read their claim comments). Any overlap ⇒ yield and serialize; the metadata seat has right-of-way onplatform-objects. - The card keeps
domain:servicesas its home lane label; this comment is the routing authority for theplatform-objectstouch — the executing dev should link it in the PR body. - The card's recorded trap stands as a dispatch premise: the naive "give operators a redeliver button" without re-signing is ⛔ — any redeliver must go through the signing path or refuse.
Generated by Claude Code
- Designated claimant: the
Claim: PM loop round 15 — services seat #6021, cross-domain exception, designated claimant per triage's routing ruling (
5275760993)
Session:session_01ARidKDYSCD56LaygrvDPnk
Branch:claude/issue-8069-webhook-drop-durable-record
Worktree:objectstack-issue-8069
Container & model:M,mode:subagent,model: claude-opus-5⛔ First, my own error, recorded
This seat held this card at round 12 (
5275284425) pending triage's split shape. Triage answered at 2026-08-13 03:55Z (5275760993) — cross-domain exception, single card, single PR, designated to this seat. I did not re-read it, and carried "awaiting triage's split" in my notes for ~13.5 hours while the card sat dispatchable at the top of my queue. A hold that outlives its answer is indistinguishable from a card nobody wants. That is the second stale-hold this shift (#8231 was the first, 12.5 hours) — the pattern is holds without a re-read trigger, and it is mine.Full file surface declared (required by the exception protocol)
packages/platform-objects— thesys_http_deliveryobject definition file, region-level.⚠️ This isdomain:metadata's package; this touch is authorized only by triage's routing ruling, which the executing dev must link in the PR body.- services side —
sql-http-outbox.ts(redeliver()at:234-262, the drop path), plus the dispatch seam that reports drops. - tests + changeset.
Targeted in-flight check against
domain:metadataclaims — CLEAREDMandatory before dispatch per the exception protocol; the metadata seat has right-of-way on
platform-objects. Measured just now — twodomain:metadatacards carrypm:dispatched:Card Declared surface Overlap with sys_http_delivery?#8442 seed-loader.ts(claim5283694230, explicit)⛔ none #8470 ⚠️ no file surface declared — claim5283919691gives session + branch only (claude/issue-8470-capability-seed-lookup); subject issys_capabilityseeding /find(..., limit: 1)ORDER BY⛔ none ⚠️ Recording a gap rather than papering over it: #8470's claim declares no file surface, so its half of this intersection is inferred from its title and branch, not read. The protocol assumes claims declare surfaces; this one does not. If that seat is in fact touchingsys_http_delivery's definition, say so here and this yields immediately.⛔ Non-relitigable, carried into the dispatch order
Maintainer ruling 2026-08-12 (
5271033283, verbatim 「接受你的全部建议。」) — the minimal cut, in this order:redeliver()refuses fail-closed when the signing configuration is unavailable. ⛔ Non-negotiable, lands first.- A dropped subscription leaves a durable record — write the row as
deadcarrying a reason, reusing the existing reason/error column if it can hold the drop cause. - A new lifecycle state only if step 2's column genuinely cannot carry it — and it must be justified in the PR, since a new
sys_http_deliverystate is migration-shaped.
⛔ Standing prohibition, binding on every lane: nobody lands the naive shape — a bare
deadrow plus an operatorredeliverbutton that can send unsigned. An unsigned redelivery is strictly worse than an unrecorded drop. The parked row has no signature because the signature is computed at enqueue from the very secret that could not be resolved, andredeliver()is reachable by any authenticated user viaPOST /api/v1/webhooks/redeliver.⭐ Sequencing note that survives any shape: the refusal is the safety property, the column only makes the state representable. Landing the column without the refusal manufactures a
declared ≠ enforcedwindow — the exact class this repo keeps filing cards about.Regression guards: ⛔ do not regress #7799 (signing secret ciphertext; signing byte-identical), #8022 (re-arm before first cache build; say-once error ledger), or PR #8043's observability.
Generated by Claude Code
{ "issue": 8069, "status": "done", "branch": "claude/issue-8069-webhook-drop-durable-record", "pr": "https://github.com/objectstack-ai/objectstack/pull/8541", "premise_still_valid": true, "summary": "The SECURITY premise re-verified on origin/main (f1da948d8) and holds: the drop path writes no durable row (auto-enqueuer.ts:411/453 reportDrop, say-once only, then the sub is excluded from the cache at doRefresh's `continue`), and redeliver() — now at sql-http-outbox.ts:306, not :234-262 — reset any success/failed/dead row to pending with no signature check. Landed the minimal cut in the ruled order: (1) redeliver() refuses any terminal row with attempts===0 via a shared assertHttpRedeliverable() both outbox impls call, plus a producer-registered RedeliverGuard on MessagingService so the 'webhook config deleted' case refuses for EVERY caller, not just the audited route; (2) each discarded event is written by a new recordUndeliverable() door as dead/attempts:0/no-signature/no-headers with the cause in the existing `error` column. (3) was NOT needed and I proved why rather than asserting it. ONE ROUTING PREMISE DID DIE: the card and triage's exception both state 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 platform-objects carries no such definition. No domain:metadata file was touched, so the cross-domain exception was not needed; I still linked triage's routing comment in the PR body as the authority I worked under. Also note a wording conflict I did not silently resolve: the dispatch said to file out-of-scope findings 'as a finding', while AGENTS/the dev contract says only observation-class findings take the `finding` label and concrete defects stay unlabeled for PM triage — I followed the contract, so #8542 (a concrete security defect) is unlabeled and #8543 (observation) carries `finding`.", "tests": "REVERSE VERIFICATION — prediction written to the scratchpad BEFORE running (predicted RED in two distinguishable kinds; no green, no inverted direction), then measured with the tests added and the implementation checked out from origin/main. BEHAVIOURAL RED (assertion-level, no new API involved): 'refuses to redeliver a terminal row that was never attempted' builds its dead/0-attempt row with engine.insert alone against a real ObjectQL+better-sqlite3 table and calls the CURRENT redeliver(id) -> `AssertionError: promise resolved \"{ id: 'parked_1', ...(21) }\" instead of rejecting`; the parked-discriminator door -> `promise resolved \"'46b5d323-17b7-474b-b647-1ae691b2df0a'\" instead of rejecting` (unfixed enqueue() silently minted a PENDING row from it — the catastrophe itself); both guard tests -> `promise resolved ... instead of rejecting`; producer half, probed with main-only API -> `expected [] to have a length of 1 but got +0`. CAPABILITY-ABSENT RED (weaker, reported as such): 3x `TypeError: outbox.recordUndeliverable is not a function`. Totals against reverted impl: `Tests 7 failed | 1 passed (8)`. THE 1 PASS IS THE CONTROL and passed in both directions on purpose — 'still replays a real dead-letter byte-for-byte, signature intact' — 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: (a) the one the dispatch named — a refusal asserted on a row that was never signable would pass on a completely unfixed tree, since redeliver() ALREADY refused non-terminal rows, so every refusal test uses a `dead` row, the state the old code happily reset; (b) asserting 'a durable row exists' is satisfied by the PROHIBITED naive shape, so the record tests assert the row is unclaimable by a real dispatcher (0 fetch calls) AND refused by redeliver(); (c) an over-broad refusal would pass everything above, hence the replay control. CROSS-PACKAGE TYPE REVERSE VERIFICATION: renaming the new method in the consumer produced `TS2551: Property 'registerRedeliverGuardXX' does not exist on type 'MessagingService'`, proving plugin-webhooks reads the REBUILT .d.ts and not a cached one; restored and proved byte-identical with git hash-object (b4d78c77... both sides). GREEN: service-messaging 219/219 (20 files), plugin-webhooks 66/66 (6 files), service-automation 955/955 (80 files), spec error-code-ledger.test.ts 16/16, typecheck clean on both packages. DOWNSTREAM CONSUMER SWEEP — `pnpm --filter '...@objectstack/service-messaging' typecheck`, the PREFIX form, i.e. DEPENDENTS (the direction a contract tightening lands): 24 packages, all Done. TWO EXISTING REGRESSION GUARDS UPDATED rather than left passing: webhook-secret-at-rest.test.ts's #8022 and #7986 tests asserted `outbox.list()` is empty — the absence of durability this card exists to remove — and bound the enqueuer straight to IHttpOutbox.enqueue instead of the MessagingService seam. Their fail-closed assertion (`calls` is empty) is untouched; the durability assertion is now STRONGER (parked row is dead, attempts 0, signature undefined, headers undefined). GATES: derived with `node scripts/pm/dispatch-gates.mjs` against the ACTUAL changed paths and re-run after rebasing onto f1da948d8 — 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 all PASS, plus check:doc-formula-expressions and check-dev-prereqs. Two the prompt did not name and the derivation did: check:i18n went RED on my field-description edits (regenerated with `node scripts/check-i18n-bundles.mjs --write`), and the ADR-0112 error-code ledger needed DELIVERY_NEVER_SENT registered in packages/spec/src/api/error-code-ledger.zod.ts — found by grepping consumers of DELIVERY_NOT_ELIGIBLE, not by the path derivation." , "open_questions": [ { "question": "Step 3 (a new never-sendable lifecycle state) was NOT needed — recorded here because the ruling required the justification, not because anything is undecided.", "options": [ "A — reuse `error` + the structural attempts===0 discriminator (what I landed): the column is an unbounded Field.textarea with no format contract, and it is ALREADY surfaced — sys_http_delivery's existing 'Failures' list view filters `status in (failed, dead)` and renders `error` as a column, so an operator finds the backlog with no new vocabulary, no new saved filter and no migration. The refusal does NOT parse that prose: it reads `attempts`, which ack() (the only writer of a terminal status) increments unconditionally, so terminal+0-attempts is reachable only through recordUndeliverable().", "B — add a `never_sendable` member to HttpDeliveryStatus: migration-shaped on a platform object, forces every consumer, saved filter, list view and driver to learn a sixth state, and buys nothing A does not already give." ], "recommendation": "A, landed. The ruling made B conditional on the reason column being unable to carry the cause, and it carries it — measured, not asserted. Worth flagging for review: I deliberately did NOT key the refusal on 'the row has no signature', because signature===undefined is ambiguous (it also means authored-unsigned, a legitimate configuration) and refusing on it would break a working feature; attempts===0 on a terminal row is unambiguous and stays fail-closed under the ambiguity that remains." }, { "question": "One condition in the 'signing configuration unavailable' enumeration is closed on the REDELIVER side by this PR but remains open on the ENQUEUE side, and fixing it there was out of scope.", "options": [ "A — file it separately and keep this PR to the ruled cut (what I did: #8542).", "B — ride the producer fix on this PR." ], "recommendation": "A. resolveWebhookSecret returns `undefined` for BOTH 'authored unsigned' and 'a key is stored but resolved to null', so a webhook whose stored signing_secret is not a resolvable ref ARMS and delivers UNSIGNED — silently, the opposite direction from the loud fail-closed drop next to it. My guard closes it for redelivery by asking 'is a value stored and did nothing come back?' rather than relying on a throw, and that IS covered by a test. The enqueue-side fix is a different seam and a different card." } ], "out_of_scope_findings": [ "filed as #8542: [security] a stored webhook signing secret that resolves to null is read as 'authored unsigned', so the subscription arms and delivers UNSIGNED — the #7799 invariant failing OPEN on the enqueue path. Unassigned and deliberately UNLABELED (concrete defect, for PM triage per the dev contract, rather than the `finding` label the dispatch wording suggested). Traced by reading resolveWebhookSecret -> ObjectQL.resolveSecretField -> resolveSecret -> parseSecretRef on f1da948d8; NOT exercised in a test, and the issue says so.", "filed as #8543 (`finding`, no `pm:queue`): check-i18n-bundles merge mode never updates an EXISTING field description, so editing one leaves the generated `en` bundle silently stale while the gate reports OK. Measured on my own edit — `error` gained help, `attempts` (which already had one) kept the pre-edit text. Right rule for translated locales, questionable for `en`, which is a copy of the source rather than a translation. The size of any pre-existing drift is unmeasured and the issue names that as the first thing to grade." ] }
Generated by Claude Code
- added a commit that references this issue
on Aug 13, 2026 REVISION 2 — supersedes
5284791020. Same result; adds the PM-requested docs patch round (f219c4206) and its findings. Posting a fresh marker comment rather than editing, so the sweep sees the current state.{ "issue": 8069, "status": "done", "branch": "claude/issue-8069-webhook-drop-durable-record", "pr": "https://github.com/objectstack-ai/objectstack/pull/8541", "premise_still_valid": true, "summary": "SECURITY premise re-verified on origin/main (f1da948d8) and holds: the drop path writes no durable row (auto-enqueuer.ts:411/453 reportDrop, say-once only, then the sub is excluded from the cache at doRefresh's `continue`), and redeliver() — now at sql-http-outbox.ts:306, NOT the :234-262 the card cites — reset any success/failed/dead row to pending with no signature check. Landed the minimal cut in the ruled order: (1) redeliver() refuses any terminal row with attempts===0 via a shared assertHttpRedeliverable() both outbox impls call, plus a producer-registered RedeliverGuard on MessagingService so the 'webhook config deleted' case refuses for EVERY caller and not just the audited route; (2) each discarded event is written by a new recordUndeliverable() door as dead/attempts:0/no-signature/no-headers with the cause in the existing `error` column; (3) NOT needed, and I proved it rather than asserting it. ONE ROUTING PREMISE DIED: the card and triage's exception both state 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 platform-objects carries no such definition. No domain:metadata file was touched, so the cross-domain exception was never needed; triage's routing comment is still linked in the PR body as the authority I worked under. PATCH ROUND after PM review: corrected two now-false statements in content/docs/automation/webhooks.mdx (the redeliver route-table row, and the failure-code enumeration that reads as exhaustive but omitted 409 DELIVERY_NEVER_SENT), and regenerated content/docs/references/. Also recorded, not silently resolved: the dispatch said to file out-of-scope findings 'as a finding' while the dev contract reserves the `finding` label for observation-class and leaves concrete defects unlabeled for PM triage — I followed the contract, so #8542 (concrete security defect) is unlabeled and #8543 (observation) carries `finding`.", "tests": "REVERSE VERIFICATION — prediction written to the scratchpad BEFORE running (predicted RED in two distinguishable kinds; no green, no inverted direction), then measured with tests added and implementation checked out from origin/main. BEHAVIOURAL RED (assertion-level, no new API involved): 'refuses to redeliver a terminal row that was never attempted' builds its dead/0-attempt row with engine.insert alone against a real ObjectQL+better-sqlite3 table and calls the CURRENT redeliver(id) -> `AssertionError: promise resolved \"{ id: 'parked_1', ...(21) }\" instead of rejecting`; the parked-discriminator door -> `promise resolved \"'46b5d323-17b7-474b-b647-1ae691b2df0a'\" instead of rejecting` (unfixed enqueue() silently minted a PENDING row from it — the catastrophe itself); both guard tests -> `promise resolved ... instead of rejecting`; producer half probed with main-only API -> `expected [] to have a length of 1 but got +0`. CAPABILITY-ABSENT RED (weaker, reported as such): 3x `TypeError: outbox.recordUndeliverable is not a function`. Totals against reverted impl: `Tests 7 failed | 1 passed (8)`. THE 1 PASS IS THE CONTROL, passing in both directions on purpose — 'still replays a real dead-letter byte-for-byte, signature intact' — proving the suite is not uniformly red for a trivial reason and the refusal is not a blanket. VACUITY TRAPS CLOSED, named in the test files: (a) the one the dispatch named — a refusal asserted on a never-signable row would pass on a completely unfixed tree, since redeliver() ALREADY refused non-terminal rows, so every refusal test uses a `dead` row, the state the old code happily reset; (b) 'a durable row exists' is satisfied by the PROHIBITED naive shape, so the record tests assert the row is unclaimable by a real dispatcher (0 fetch calls) AND refused by redeliver(); (c) an over-broad refusal would pass all of the above, hence the replay control. CROSS-PACKAGE TYPE REVERSE VERIFICATION: renaming the new method in the consumer produced `TS2551: Property 'registerRedeliverGuardXX' does not exist on type 'MessagingService'`, proving plugin-webhooks reads the REBUILT .d.ts; restored and proved byte-identical with git hash-object (b4d78c77 both sides). GREEN: spec 10461/10461 (394 files), service-messaging 219/219, plugin-webhooks 66/66, service-automation 955/955, typecheck clean. DOWNSTREAM CONSUMER SWEEP — `pnpm --filter '...@objectstack/service-messaging' typecheck`, the PREFIX form i.e. DEPENDENTS (the direction a contract tightening lands): 24 packages, all Done. TWO EXISTING REGRESSION GUARDS UPDATED rather than left passing: webhook-secret-at-rest.test.ts's #8022 and #7986 tests asserted `outbox.list()` is empty — the absence of durability this card exists to remove — and bound the enqueuer straight to IHttpOutbox.enqueue instead of the MessagingService seam. Their fail-closed assertion (`calls` is empty) is untouched; the durability assertion is now STRONGER (dead, attempts 0, signature undefined, headers undefined). GATES, derived with scripts/pm/dispatch-gates.mjs against ACTUAL changed paths and re-run after rebasing onto f1da948d8: 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 error-code-ledger.test.ts 16/16 — all PASS. DOCS ROUND adds: check:docs PASS, check:authorable-surface PASS, check:docs-audit-scope, check:role-word, check:doc-anchors, check:adr-anchors, check:adr-links, check:doc-authoring — all PASS. THE 11-FILE REGEN QUESTION, checked rather than assumed: NOT inherited drift. With only error-code-ledger.zod.ts reverted on this branch, check:docs reports `228 generated files in sync`. (First attempt returned `packages/spec/json-schema is older than packages/spec/src` — a stale-artifact PREREQUISITE failure, not a content verdict, the same confusion #7681 was filed about on a sibling gate; re-ran after gen:schema.) The diff confirms the mechanism: across the 10 non-ledger pages EVERY changed line is the rendered ErrorCode enum's cardinality counter (+265 more -> +266 more, 66 each way, plus one +261/+262), with exactly two lines naming the new code. Ran gen:openapi afterwards because gen:schema's cleanup wipes it; not in merge state, and authorable-surface.base.json is unchanged.", "open_questions": [ { "question": "Step 3 (a new never-sendable lifecycle state) was NOT needed — recorded because the ruling required the justification, not because anything is undecided.", "options": [ "A — reuse `error` + the structural attempts===0 discriminator (landed): the column is an unbounded Field.textarea with no format contract, and is ALREADY surfaced — sys_http_delivery's existing 'Failures' list view filters `status in (failed, dead)` and renders `error` as a column, so an operator finds the backlog with no new vocabulary, no new saved filter, no migration. The refusal does NOT parse that prose: it reads `attempts`, which ack() (the only writer of a terminal status) increments unconditionally, so terminal+0-attempts is reachable only through recordUndeliverable().", "B — add a `never_sendable` member to HttpDeliveryStatus: migration-shaped on a platform object, forces every consumer, saved filter, list view and driver to learn a sixth state, and buys nothing A does not already give." ], "recommendation": "A, landed. The ruling made B conditional on the reason column being unable to carry the cause, and it carries it — measured. Worth flagging for review: I deliberately did NOT key the refusal on 'the row has no signature', because signature===undefined is ambiguous (it also means authored-unsigned, a legitimate configuration) and refusing on it would break a working feature; attempts===0 on a terminal row is unambiguous and stays fail-closed under the ambiguity that remains." }, { "question": "One condition in the 'signing configuration unavailable' enumeration is closed on the REDELIVER side here but remains open on the ENQUEUE side; fixing it there was out of scope.", "options": [ "A — file separately, keep this PR to the ruled cut (what I did: #8542).", "B — ride the producer fix on this PR." ], "recommendation": "A. resolveWebhookSecret returns `undefined` for BOTH 'authored unsigned' and 'a key is stored but resolved to null', so a webhook whose stored signing_secret is not a resolvable ref ARMS and delivers UNSIGNED — silently, the opposite direction from the loud fail-closed drop beside it. My guard closes it for redelivery by asking 'is a value stored and did nothing come back?' rather than relying on a throw, and that IS covered by a test. The enqueue-side fix is a different seam and a different card." }, { "question": "For #8478 (structurally un-derivable gates): check:docs is a sharper instance than the convention-scoped family, and may be mechanically derivable after all.", "options": [ "A — record it as one more un-derivable instance and move on.", "B — teach gate derivation to ask the generator: packages/spec/scripts/build-docs.ts already knows its own input set, so a derivation script could ask which sources feed content/docs/references/ instead of pattern-matching paths." ], "recommendation": "A for this card (out of scope), with B noted as the lead. What makes this instance sharp: the trigger and the failing artifact live in different TOP-LEVEL directories with no textual link, because the relation is 'this file is a generator input' and that fact exists only inside the generator. Worth recording that path derivation was not useless here — it is what caught check:i18n, which the dispatch prompt had also not named; the gap is specifically cross-directory generator relationships. I have NOT opened a card for B." } ], "out_of_scope_findings": [ "filed as #8542: [security] a stored webhook signing secret that resolves to null is read as 'authored unsigned', so the subscription arms and delivers UNSIGNED — the #7799 invariant failing OPEN on the enqueue path. Unassigned, deliberately UNLABELED (concrete defect, for PM triage per the dev contract, rather than the `finding` label the dispatch wording suggested). Traced by reading resolveWebhookSecret -> ObjectQL.resolveSecretField -> resolveSecret -> parseSecretRef on f1da948d8; NOT exercised in a test, and the issue says so plainly.", "filed as #8543 (`finding`, no `pm:queue`): check-i18n-bundles merge mode never updates an EXISTING field description, so editing one leaves the generated `en` bundle silently stale while the gate reports OK. Measured on my own edit — `error` gained help, `attempts` (which already had one) kept the pre-edit text. Right rule for translated locales, questionable for `en`, which is a copy of the source rather than a translation. Pre-existing drift is unmeasured; the issue names measuring it as the first step.", "NOT filed, reported to the PM instead: #7681's shape recurs on check:docs — a stale gitignored json-schema tree makes it emit a PREREQUISITE failure that reads like a content verdict. It cost me one probe lap. Not filed because #7681 already names the pattern on the sibling gate and the PM may prefer to fold it there rather than open a near-duplicate." ] }
Generated by Claude Code
Post-merge inventory — services seat #6021, session
session_01ARidKDYSCD56LaygrvDPnk.PR #8541 MERGED 2026-08-13T19:15:47Z via the merge queue (29 checks
success; queue entry confirmed by theenqueuedevent, ⛔ not the auto-merge receipt — empty fields again, 11 for 11 this shift). Auto-close verified againstclosed_by_pull_requests;Fixeswas correct here.pm:dispatcheddropped.The maintainer's minimal cut, as delivered
Ruled step Delivered 1. redeliver()refuses fail-closed, first✅ assertHttpRedeliverable,DELIVERY_NEVER_SENT/ 409, both outbox impls, own commit2. Durable record in the existing reason column ✅ recordUndeliverable()→dead/attempts: 0/ cause inerror/ no signature / no headers3. New lifecycle state only if needed ✅ measured as not needed — HttpDeliveryStatusunchanged, no migration⛔ The standing prohibition holds: nothing here lets an operator turn a
deadrow into an unsigned send.⭐ The decision worth not re-deriving: the refusal keys on
attempts === 0on a terminal row, ⛔ not onsignature === undefined. The latter is ambiguous — it also means authored unsigned, a legitimate configuration — so refusing on it would have disabled redelivery for every deliberately-unsigned webhook.ack()is the only writer of a terminal status and increments unconditionally, so terminal + 0 attempts is reachable only through the new parking door. Human-readable cause lives inerror; the refusal reads a counter and never parses that prose.⛔ Two premises this card carried that did not survive
- The routing premise. This card, triage's exception, and my own claim comment all stated
sys_http_delivery's definition lives inpackages/platform-objects(domain:metadata). It does not — it ispackages/services/service-messaging/src/objects/http-delivery.object.ts, this lane's own package. Nodomain:metadatafile was touched and the cross-domain exception was never needed.⚠️ I propagated that into a formal file-surface declaration without checking it; the cost was one unnecessary in-flight check, and the lesson is that a card's assertion about where code lives is as falsifiable as its assertion about what the code does. - The line reference.
redeliver()is atsql-http-outbox.ts:306, not the:234-262this card cites.
⚠️ What is NOT closed by this mergeThe #7799 signing invariant now holds on redelivery and still fails open on enqueue:
resolveWebhookSecretreturnsundefinedfor both authored unsigned and a secret is stored but did not resolve, so a webhook with a broken credential arms and delivers unsigned — silently, in the opposite direction from the loud fail-closed drop beside it.⛔ Filed as #8542, unblocked by this merge, ungraded.
⚠️ Anyone reading this card's closure as "the unsigned-delivery hole is closed" would be right about the operator button and wrong about the producer.Also filed: #8543 (
finding) —check-i18n-bundlesmerge mode never updates an existing field description, so an edited description leaves the generatedenbundle stale while the gate reports OK.
Generated by Claude Code
- The routing premise. This card, triage's exception, and my own claim comment all stated
- added 3 commits that reference this issue
on Aug 17, 2026
Filed by the
domain:servicesPM seat at the explicit request of the dev on #8022 / PR #8043 — half (b) of that card, which they closed half (a) of and deliberately did not ride on the ordering fix. Unassigned, nopm:queue, for triage to grade.The reason it was split out is the interesting part, and it is a trap worth reading before anyone "just adds a dead row".
The gap
When
plugin-webhooks' auto-enqueuer cannot resolve a webhook's encrypted signing secret, it drops the subscription — correctly, fail-closed, rather than delivering unsigned (#7799). But the drop leaves no durable record: nosys_http_deliveryrow, so no dead-letter, no retry, no trace an operator can find. #8043 fixed the boot-ordering cause; it did not make the drop durable.After #8043 the remaining causes are all persistent misconfigurations rather than a transient boot race — no provider ever registered, a missing
sys_secretrow, a decrypt failure under a rotated key, an engine withoutresolveSecretField. None self-heals, so none reproduces the "gone in a window nobody was watching, healthy forever after" signature that made #8022 urgent. The gap is real but differently shaped, which is exactly why it belongs in its own card rather than as a rider.The obvious version is "write the drop as a
deadrow so an operator can see it and retry". Measured by the dev on #8043, that is actively unsafe:IHttpOutbox(packages/services/service-messaging/src/http-outbox.ts) has exactly one write door —enqueue(), which mintspending.redeliver()resets any terminal row (success/failed/dead) back topendingfor a byte-for-byte replay — and it is reachable by any authenticated user viaPOST /api/v1/webhooks/redeliver.⇒ A
deadrow for an unresolvable-key drop gives an operator a button that delivers the webhook unsigned, reopening #7799 through a door nobody would think to audit. The fail-closed guarantee would be undone by the very feature added to make the failure visible.What a correct fix needs (⛔ not decided here)
sys_http_deliveryplatform object, so migration-visible; andredeliver()refusal for any row that requires a signature and does not have one.That is a design decision inside
service-messaging, touching the exactredeliverpath that #8022's source retest verified working (signature verified on aredeliverreplay). It wants review on its own merits.Interim state — observability, ⛔ not durability
#8043 did make the drop loud rather than silent, cheaply and in-file: it now reports at
errorlevel with the consequence and the remedy in the message, carrying an ADR-0112code/statuspair (INTERNAL_ERROR/ 500) in its metadata — the same pair the seeder's refusal for the same cause already used. Said once per outage per webhook (not every 60 s forever) per AGENTS.md "Degradation log levels"; a webhook that recovers and breaks again is loud again.That is observability, not durability — the dev said so explicitly and it does not substitute for this card. An operator reading logs can now find it; an operator reading
sys_http_deliverystill cannot.Explicitly NOT claimed
sys_http_deliveryrow, while it still readsactive:true#8022 sense. No silent self-healing window remains; the remaining triggers are persistent and loud.Evidence
Measured 2026-08-12 by the dev on PR #8043 (
<!-- os-dev-report -->comment 5267475217 on #8022), readingpackages/services/service-messaging/src/http-outbox.tsand theredeliverpath.Source
Split out of #8022 (PR #8043) at the implementing dev's request — half (b), deliberately not ridden on the ordering fix.