Found by a retest of integration-system.webhook-lifecycle (QA run #7690) against origin/main @ b602d536, rebuilt. The fix under retest — #7722/#7798 (delivery rows store the signature, not the secret) and #7799/#7901 (encrypt sys_webhook.signing_secret) — is otherwise verified correct. This card is about a new failure the second half introduced.
Impact
For roughly 60 seconds after every server start, a webhook that holds an encrypted signing secret is not subscribed. A record change in that window produces:
- no delivery to the receiver, and
- no
sys_http_delivery row at all — so there is no dead-letter, no retry, no durable trace that anything was missed,
while GET /api/v1/data/sys_webhook/<id> still reads active: true, i.e. it looks armed in Setup. The event is simply gone. It self-heals at the next periodic cache refresh (60 s), so it is invisible to anyone not looking in that window.
The affected population is exactly the one #7799 created: webhooks with an encrypted signing_secret. Before #7799 no secret needed decrypting at cache-build time, so this window did not exist.
Reproduction (2/2 — 11:29 and 11:33)
- Give an active webhook a signing secret:
PATCH /api/v1/data/sys_webhook/<id> {"active":true,"url":"<receiver>","signing_secret":"<literal>"}
The engine stores secret:sec_<id> and encrypts into sys_secret. - Restart the server on the same DB.
- Within ~60 s of boot, mutate a record the webhook subscribes to.
Expected: one receiver hit and one sys_http_delivery row.
Actual: no receiver hit, and no sys_http_delivery row.
After the 60 s cache refresh, the same mutation delivers correctly and its signature verifies.
Root cause — boot ordering, not the fail-closed choice
From the server log of the 11:33 run:
11:33:35.145 WARN [webhook-auto-enqueuer] webhook 'showcase_task_changed' holds an encrypted
signing secret that could not be decrypted — the subscription is DROPPED rather
than delivered unsigned (#7799)
err: "Cannot resolve secret: no CryptoProvider is registered (fail-closed)."
11:33:36.4xx INFO CryptoProvider configured for secret fields
The webhook-auto-enqueuer builds its subscription cache ~1.3–2 s before the CryptoProvider is registered. It cannot decrypt, and — correctly — refuses to deliver unsigned, so it drops the subscription. Nothing re-arms it until the periodic 60 s refresh (cache refreshed {objects:1,rows:1} observed at 11:29:45).
The fail-closed decision is right; the ordering is the bug. Dropping unsigned deliveries is the correct choice — the problem is that the enqueuer's first cache build races the crypto registration, and that the drop leaves no durable record.
Suggested shape (not prescriptive)
Either make the first cache build wait on CryptoProvider registration (or rebuild immediately on the registration event) instead of leaving a 60 s hole, or — if a drop is ever legitimate — write a sys_http_delivery row in a failed/deferred state so the miss is durable and retryable rather than silent. The silence is arguably the worse half: a fail-closed drop that leaves a trace is an operational event; one that leaves nothing is data loss.
What is verified working (so this card is narrowly scoped)
The retest confirms the secret fix itself is sound, and none of this should be reverted:
- The secret is genuinely gone from the delivery table.
sys_http_delivery has no secret column; it now carries signature instead. A byte-level full-table/full-column scan of both live SQLite files (data.db: 88 tables / 1395 columns / 4227 cells; data.telemetry.db: 9 tables / 186 columns / 3689 cells) plus a raw grep -a -F over data.db, -wal and -shm found 0 occurrences of the secret literal — against a positive control (an unrelated literal returned 24 hits, the sec_ handle 2 hits) proving the scanner reads live WAL data. - Signing still works everywhere it must: recomputed
HMAC-SHA256(raw body, secret) matched x-objectstack-signature on all three per-record variants, on a 4th-attempt retry after receiver downtime, on a delivery after a full restart where the secret exists only as ciphertext, and on a redeliver replay. - Steady-state delivery, the retry ladder (
pending → attempts 4 → success), trigger-enum enforcement and the customized-row-survives-reseed behaviour all pass.
Source
Retest of integration-system.webhook-lifecycle at b602d536. The two fixes under retest do not interfere with each other; this defect belongs entirely to the #7799 half.
Found by a retest of
integration-system.webhook-lifecycle(QA run #7690) againstorigin/main@b602d536, rebuilt. The fix under retest — #7722/#7798 (delivery rows store the signature, not the secret) and #7799/#7901 (encryptsys_webhook.signing_secret) — is otherwise verified correct. This card is about a new failure the second half introduced.Impact
For roughly 60 seconds after every server start, a webhook that holds an encrypted signing secret is not subscribed. A record change in that window produces:
sys_http_deliveryrow at all — so there is no dead-letter, no retry, no durable trace that anything was missed,while
GET /api/v1/data/sys_webhook/<id>still readsactive: true, i.e. it looks armed in Setup. The event is simply gone. It self-heals at the next periodic cache refresh (60 s), so it is invisible to anyone not looking in that window.The affected population is exactly the one #7799 created: webhooks with an encrypted
signing_secret. Before #7799 no secret needed decrypting at cache-build time, so this window did not exist.Reproduction (2/2 — 11:29 and 11:33)
PATCH /api/v1/data/sys_webhook/<id> {"active":true,"url":"<receiver>","signing_secret":"<literal>"}The engine stores
secret:sec_<id>and encrypts intosys_secret.Expected: one receiver hit and one
sys_http_deliveryrow.Actual: no receiver hit, and no
sys_http_deliveryrow.After the 60 s cache refresh, the same mutation delivers correctly and its signature verifies.
Root cause — boot ordering, not the fail-closed choice
From the server log of the 11:33 run:
The
webhook-auto-enqueuerbuilds its subscription cache ~1.3–2 s before theCryptoProvideris registered. It cannot decrypt, and — correctly — refuses to deliver unsigned, so it drops the subscription. Nothing re-arms it until the periodic 60 s refresh (cache refreshed {objects:1,rows:1}observed at 11:29:45).The fail-closed decision is right; the ordering is the bug. Dropping unsigned deliveries is the correct choice — the problem is that the enqueuer's first cache build races the crypto registration, and that the drop leaves no durable record.
Suggested shape (not prescriptive)
Either make the first cache build wait on CryptoProvider registration (or rebuild immediately on the registration event) instead of leaving a 60 s hole, or — if a drop is ever legitimate — write a
sys_http_deliveryrow in a failed/deferred state so the miss is durable and retryable rather than silent. The silence is arguably the worse half: a fail-closed drop that leaves a trace is an operational event; one that leaves nothing is data loss.What is verified working (so this card is narrowly scoped)
The retest confirms the secret fix itself is sound, and none of this should be reverted:
sys_http_deliveryhas no secret column; it now carriessignatureinstead. A byte-level full-table/full-column scan of both live SQLite files (data.db: 88 tables / 1395 columns / 4227 cells;data.telemetry.db: 9 tables / 186 columns / 3689 cells) plus a rawgrep -a -Foverdata.db,-waland-shmfound 0 occurrences of the secret literal — against a positive control (an unrelated literal returned 24 hits, thesec_handle 2 hits) proving the scanner reads live WAL data.HMAC-SHA256(raw body, secret)matchedx-objectstack-signatureon all three per-record variants, on a 4th-attempt retry after receiver downtime, on a delivery after a full restart where the secret exists only as ciphertext, and on aredeliverreplay.pending → attempts 4 → success), trigger-enum enforcement and the customized-row-survives-reseed behaviour all pass.Source
Retest of
integration-system.webhook-lifecycleatb602d536. The two fixes under retest do not interfere with each other; this defect belongs entirely to the #7799 half.