Skip to content

Lite opens time follow-ups and signs generic webhooks (#6012) - #6064

Merged
erikdarlingdata merged 8 commits into
devfrom
fix/6012-lite-followups-signing
Oct 18, 2026
Merged

erikdarlingdata merged 8 commits into
devfrom
fix/6012-lite-followups-signing

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Closes #6012.

Why

Lite had no time follow-ups and sent generic webhooks unsigned. Darling got both in #6008 (time follow-ups) and #5998 (Standard Webhooks signing). This is the Lite side (decision D19 A: time only, no deploy condition. D1 to D4 signing).

What changes

Part A, time follow-ups (done).

  • Schema v75: follow_up_of and follow_up_at on investigation_requests, in the shared AddedColumns list so a start heals a missing column.
  • submit_investigation_report takes follow_up_at, checked by the shared ValidateFollowUp (past, or over 30 days, is refused as invalid). The description says Lite opens a time follow-up only.
  • The store counts a route's pending follow-ups under the write lock. At 20 the report is refused with FollowUpLimit (same words as Darling) and nothing is stored.
  • SweepAsync opens each due follow-up once, in its own transaction after the resolved step: a new request with follow_up_of, a copy of the source's alert rows, and one follow_up journal row (trigger time, once per earlier request). It returns the count in FollowUps.
  • A purge clears a follow_up_of that names a request it deletes (as Lite: recurrence, server names and report fields (#5632 Phase C L2) #6011 does for family_of).
  • The follow-up's brief carries the earlier report through the existing earlier-reports read (same server and family). The test proves it.
  • Lite has no event delivery and no long poll (Lite investigation requests journal a quiet join and wait for every metric to resolve (#6010) #6057 did not port them), so an agent finds a follow-up in list_investigation_requests. I added no delivery.
  • Test-side contract: InvestigationToolContract now lists follow_up_at as shared. Only follow_up_condition stays Darling-only. The Lite golden tool-text SHA moved for the same reason.

Part B, signed generic webhooks (done).

  • The shared sender already signs. Lite now supplies the keys: AppAlertSettings.GenericWebhookSigning and GenericSigningKeyUnreadable for the settings-row URL, and NotificationRoute.GenericSigning for a route's own URL.
  • Where the key is kept. Lite's webhook headers JSON is in Windows Credential Manager, not plain text. The signing key goes the same way, through RouteSecretAccess: GenericWebhookSigningKey and ...Previous for the settings row, NotificationRouteSigning_<id>_Current/Previous for a route. Only a has-key flag and the previous key's end time are plain: generic_signing_set and generic_signing_until in settings.json, and generic_signing_set and generic_signing_until columns on notification_routes (v75). The key is never in settings.json, DuckDB, a log, a label, a tooltip, the clipboard or an error message.
  • A flag with an unreadable key turns the destination off: the settings row's URL is disabled, and a route's generic channel goes into OffChannels with no fallback to the app-wide URL. A signed destination is never sent unsigned.
  • Screens: Settings has Generate or Rotate key and Remove key. The route dialog has the same. A new key is shown once in a read-only box with Darling's words. Afterwards the screen says only that a key is saved and when a rotation ends. Rotation keeps the previous key signing for 7 days (WebhookSigning.RotationOverlap).
  • The Test button signs with the saved key: SendTestGenericAsync takes an optional signing argument. Darling's callers pass nothing.
  • A URL change keeps the key in Lite. Darling refuses it because its seal binds the key to the URL text. Credential Manager does not bind it. A route keeps its key when its URL is replaced.
  • Diagnostics: Lite has no support bundle that lists settings or routes. The key appears only in the two Credential Manager entries.

Test plan

  • Mac build, Lite.Tests: 0 warnings, 0 errors.
  • VM, Lite.Tests, my classes plus grep -l classes for every changed file (merged dev at 1f431e207): Total: 419, Failed: 0.
  • VM, Darling.Tests (no store): *SignedWebhook* *InvestigationToolContract* *McpToolGuide* *ViewerSigningKey* *WebhookAlertService* *DarlingMcpInvestigationTools* *InvestigationReportFields* *AlertDeliveryChannel* *GenericWebhook* *WebhookPostTimeout*: Total: 344, Failed: 0, Skipped: 5.
  • Revert proof (temporary branch, deleted): with the sweep, the cap, the tool validation and the key wiring removed, 7 of the 12 new tests fail. The one pin on GenericSigningKeyUnreadable was not reverted separately. The old code has no key flag, so it cannot pass there.
  • Round 2 (seat review): flag save before the key is shown, rollback, route dialog unreadable state. Mac build, Lite.Tests: 0 warnings, 0 errors. VM, Lite.Tests at ba9a38333 (merged with dev): *LiteSignedWebhookTests* *NotificationRouteStoreTests* *SettingsSampleTests* *SettingsValueReadTests* plus every class that grep -l names for the changed files (AppAlertSettings, SettingsSaveReport, SettingsFileGuard, SettingsWindowTabsLite, AlertSettingsControlWiring, AlertSettingsCredentialLoad, NotificationRouteUi, NotificationRouteParity, NotificationRouteRealCredential, NotificationRouteSecretGeneration, NotificationRouteSecretSave, NotificationRoutesMcp, NotificationRouteTruthfulness, GenericWebhook, McpSettingsGuard, ClickThroughUiText): Total: 241, Failed: 0. The seat's full run at 87f3b4cae found one failure, my own Remove pin (fixed, above). Darling.Tests was 34076 with 0 failed at 1f431e207. Round 2 changed no Darling file and no shared model, so I did not rerun it.
  • Full suite with verify-full.sh (seat) at ba9a38333: Darling.Tests Total: 34076, Errors: 0, Failed: 0, Skipped: 3914, Not Run: 1, Lite.Tests Total: 10703, Errors: 0, Failed: 0, Skipped: 5. Both builds had 0 warnings and 0 errors.

Pins: LiteInvestigationFollowUpTests (sweep opens once at its time and the brief carries the earlier report, the 21st is refused and nothing is stored, 31 days is refused through the tool, the purge clears a dangling follow_up_of, v75 on a new and an old file) and LiteSignedWebhookTests (the three headers over the exact bytes and a receiver check with one changed byte, both keys verify in a rotation, the key is in the secret store and in no table or text, an unreadable key sends nothing and does not fall back, and no key means unsigned as before). Existing pins I updated: the v74 schema asserts to v75, the route column count 19 to 21, the sample settings.json list, the golden tool-text SHA.

Round 2 (seat review): what changes

  • A new key is shown only after its flag saved. LiteWebhookSigningKeys.Generate has an overload that takes saveState. It writes the key, calls the save, and if the save fails or throws it puts Credential Manager back (old current under the current name, old previous under the previous name, or deletes a name that held nothing) and returns an error and no key. If the put-back fails too, the message says Credential Manager may hold a key that was never shown. Both callers use it.
  • Settings row. New App.TryWriteSetting returns whether settings.json was written (WriteSetting now calls it and keeps its behaviour). Generate and Rotate move the in-memory flag with the file and put it back when the write fails. Remove: if the flag cannot be written the in-memory flag is left as the file has it, so the URL reads as unreadable and is held back, and the message says so (RemoveNotSavedText).
  • Route. GenerateSigningKeyAsync runs the flag UPDATE through the same step. A failed UPDATE returns (null, error) and nothing throws into the click handler. RemoveSigningKeyAsync says the settings were not saved and the route is held back when its UPDATE fails.
  • Route dialog. New Lite-only read DuckDbNotificationRouteStore.SigningStateAsync(routeId) returns the row's flag, rotation end and an Unreadable bit (flag set and the key will not open. Not set for a channel that is off because its URL secret will not open). The dialog reads it on load and after Generate or Remove, so an unreadable key shows the unreadable text, offers Rotate key and enables Remove, and the rotation end shown is the saved one, not now plus 7 days. NotificationRoute is unchanged, so no Darling class is affected. The dialog click handlers also catch an exception from the store delegate, so a click never ends the app.
  • Writer check: the flag is written by TryWriteSigningFlag (route) and PersistGenericSigning (settings), both from the screens' Generate and Remove clicks. The readers are Hydrate, SigningStateAsync and LoadAlertSettings.

Round 2: pins

New in LiteSignedWebhookTests (8): first key with a failing save (no key, error, Credential Manager empty). Rotation with a failing save (old current and previous restored). Rotation with no previous key and a throwing save (no previous name left behind). Failing save plus a refusing put-back (message says so, no key). A route whose flag UPDATE fails on Generate and on Remove. The store read reports unreadable for flag set and no key, and not unreadable for a channel off because its URL will not open. The store read reports no rotation end when the old key would not open, and the saved end otherwise. The route UPDATE is made to fail with a unique index on generic_signing_set (a second route going to the same value is a real DuckDB constraint error). The first run of the Remove pin failed because new routes have a NULL flag and a unique index lets NULLs repeat. The test now sets the other routes to false first. That was a test bug found by the seat's full run, not a product bug.

Revert proof (temporary branch, deleted): with the save step ignored, the Remove flag check off and Unreadable forced false, 7 of the 8 new pins fail (the "not unreadable for a URL-off channel" pin and the rotation-end pin hold on the old code too. They guard the cases the fix must not break). The screen change in the route dialog has no pin: a test cannot click the dialog.

CHANGELOG entry

SECTION: Added
ENTRY: - Lite opens time follow-ups and signs generic webhooks ([#6012]) - An agent report can ask for a follow-up request at a time up to 30 days ahead, and Lite opens it with the earlier report in its brief. Lite's generic webhook can carry a per-destination signing key (Standard Webhooks headers), kept in Windows Credential Manager and shown once.
REF: [#6012]: #6012

Deferred and follow-ups

  • Not done: the deploy follow-up condition (D19 A, Darling only). Follow-ups for Lite events and long poll (not ported by Lite investigation requests journal a quiet join and wait for every metric to resolve (#6010) #6057).
  • Not run on a real Windows Credential Manager: the new key screens (WPF windows) were built and compile. The tests use the in-memory secret store. A person should click Generate, Rotate and Remove once.
  • Round 2: no screen pin for the route dialog (a test cannot click it). A failed Remove flag write on the settings row leaves the destination held back until the user tries Remove again (fail closed, by design).
  • No follow-up issues proposed.

@erikdarlingdata
erikdarlingdata marked this pull request as ready for review October 18, 2026 07:48
@erikdarlingdata
erikdarlingdata merged commit 3a46a9d into dev Oct 18, 2026
52 of 56 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/6012-lite-followups-signing branch October 18, 2026 08:06
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Claude posting for Erik Darling.

The signing-key screens work against a real Windows Credential Manager. This is the click test the PR body left open ("A person should click Generate, Rotate and Remove once"). PowerShell UI Automation drove a Lite built from dev at da7cf8832 on a Windows 11 VM, with no monitored server. No defect was found.

Settings page (Delivery tab):

  • Generate key: PASS. The read-only box showed a whsec_ key once. The current Credential Manager entry matched it exactly, and settings.json had the flag set. After the page was reopened, it said only that a key is saved.
  • Rotate key: PASS. The previous entry held the old key and the current entry a new one. The page showed the rotation end, 7 days later.
  • Remove key: PASS. Both entries were gone, the flag was cleared, and the button went back to Generate key.
  • Test button: PASS. A local listener received the webhook-id, webhook-timestamp and webhook-signature headers. During the rotation the signature had two v1, values, and each matched an HMAC-SHA256 computed with one of the two keys.

Route dialog (Manage Notification Routes, Add Route, then Edit):

  • Generate key: PASS. The key was shown once and matched the route's current entry. It was not shown again after a reopen, or after a Lite restart.
  • Rotate key: PASS. The previous and current entries were correct, and the dialog showed the rotation end.
  • Remove key: PASS. Both entries were gone. The dialog showed "No key" with Remove disabled, also after a restart.

Not checked: the route row's signing flag in the DuckDB file, because Lite holds the file open while it runs. The behaviour above depends on that flag, and it acted correctly across restarts.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant