Reach the delivery cooldown through the control plane as delivery.cooldown_minutes - #3336
Conversation
…ldown_minutes get_alert_settings reports it and update_alert_settings accepts it, under a channel-neutral name beside delivery.mode / delivery.per_event_max. The stored column name email_cooldown_minutes stays, and is accepted as a write-only alias. The value lives on config_notification rather than config_alert_settings, so the tool now spans two singleton config rows: a second read constant, a per-field table on the partial-update parser, and one UPDATE statement per table inside one transaction. The round-trip invariant is held per table. The mcp role gains UPDATE on exactly email_cooldown_minutes -- column-level, because config_notification holds the SMTP password blob and the Teams / Slack / generic webhook URLs and the PagerDuty routing key. The read side needs nothing: that column is already in the section-6 non-secret carve. Lite's get_alert_settings reports the same key off the same shared throttle.
…nd-trip both tables live McpReadCommandTimeoutTests classifies every command on the MCP surface as addressing the store or a monitored target, and the three-argument new NpgsqlCommand(sql, connection, transaction) form is the monitored-target shape. Assign the transaction instead of passing it, rather than widening an allowlist whose whole job is to keep a target command from taking a store bound. The live write test now drives the cooldown through the tool under both spellings, spans both tables in one body, and restores both singletons it moves. Darling/README.md enumerates the mcp role's writes in four places; each names the new single-column config_notification grant, so the security claim stays true.
…down-control-plane
A count comparison is satisfied by two empty sets, and an emptied SELECT list is the accident that would produce them.
…down-control-plane
It sits in a block headed "SMTP alert delivery (optional)", so an operator with no SMTP skips the section that holds the only throttle on their webhook volume.
|
Reviewed the diff (Darling C#/PostgreSQL + Lite parity; no T-SQL touched, so the T-SQL style section doesn't apply here). Overall: this is a careful, well-tested change. The two-table write is correctly wrapped in a single transaction with a fixed statement order (avoids deadlocking against itself on concurrent calls) and rolls back cleanly on partial failure via Lite/Darling parity: One minor note (posted inline): No SQL injection, secret-handling, or missing-index-DMV concerns found. |
Two independent reads let an update_alert_settings commit land between them and hand the caller a payload mixing pre- and post-update state across the two tables. The write path refuses to leave a half-landed state; a read that can report one put the asymmetry back, and the post-write re-read is described to the caller as the authoritative merged state, which it would not have been. REPEATABLE READ is the mechanism, not a detail: PostgreSQL takes a fresh snapshot per statement under READ COMMITTED, so a default transaction around the two SELECTs would read as a fix and change nothing. The two single-table reads are now private and take the combined method's connection and transaction, so the split cannot be reintroduced by calling one alone -- it does not compile.
Review summaryWent through the full diff (Darling reader/tools/roles, the three test files, Specifically checked and found sound:
Nothing to request changes on. |
Fixes #3314.
email_cooldown_minuteswas the only throttle between a fired alert and a Slack / Teams / PagerDuty / generic-webhook post, and it was named for email, stored in the SMTP config block, absent fromget_alert_settings, refused byupdate_alert_settings, and writable only from the WPF Settings window. On a headless deployment with Slack configured and no SMTP at all, the sole path to the number governing channel volume was a desktop app.get_alert_settingsnow reports it andupdate_alert_settingsnow accepts it asdelivery.cooldown_minutes, beside thedelivery.mode/delivery.per_event_maxit belongs with. The stored column keeps its name and is accepted as a write-only alias, so existing configs, the Lite Settings window and any hand-written UPDATE keep working; sending both spellings in one body is refused naming both, rather than letting one win silently. Two wire keys for one setting is its own bug for a client, so only the canonical name round-trips.The tool descriptions now say the two cooldowns are different stages — top-level
cooldown_minutesgates whetherAlertEngineFIRES,delivery.cooldown_minutesthrottles the resulting post — because an operator readingcooldown_minutes: 5reasonably concluded that was the delivery cadence.Two tables, one tool
The value is on
config.config_notification; every other knob is onconfig.config_alert_settings. That is the design decision here, and it is why the column reached 3.5.0 unreachable.DarlingAlertReadergains a second read constant, and the partial-update parser carries a table per field instead of inferring one from the column name — an inference that would be right today and silently wrong the first time a second notification knob arrives. The write issues one statement per table in a fixed order inside one transaction: a partial application is the worst outcome available, since the caller would be toldupdatedand handed a re-read that half-landed with no indication which half.The round-trip invariant
EveryColumnRead_IsEmittedByThePayload_AndAcceptedByTheWriteris now held per table, driven off the tool's own table list. A single equality over the union would be satisfiable by compensating drift — a column dropped from one table's write set and a stray added to the other's net to the same set — and it is weakest exactly where the new plane is thinnest.updated_fieldsstays unqualified, because qualifying it would redefine every existing entry of a consumer-visible array; a new test asserts no writable column name appears on both tables, which is what makes a bare name unambiguous.Neither statement touches
modified_at, matching what this tool has always done toconfig_alert_settings. Both Viewer upserts do bump it, and the asymmetry is checked rather than inherited: nothing readsmodified_at— it is in no viewer projection, no payload and no decision — and bumping it here would mean grantingmcpUPDATE on a second column of a secret-bearing table to maintain a value with no reader.Both reads happen under one snapshot.
DarlingAlertReader.GetAlertConfigurationAsyncopens one connection and one transaction and returns both halves, and both tool paths go through it —get_alert_settingsand the post-write re-read. Two independent reads could straddle a concurrentupdate_alert_settingscommit and return a stalecooldown_minutesbeside a freshdelivery.cooldown_minutes, which is the same half-landed state the write path takes a transaction to avoid producing, and the re-read is described to the caller as the authoritative merged state.REPEATABLE READis the mechanism rather than a detail: PostgreSQL takes a fresh snapshot per statement underREAD COMMITTED, so a default transaction around the two SELECTs would read as a fix and change nothing. The two single-table reads are private and take the combined method's connection and transaction, so the split cannot come back by calling one alone — it does not compile.get_alert_settingsreportsunavailablewhen the notification row is absent, naming it. The service seeds both singletons in one pass, so "settings present, notification absent" is not a state the product produces, and reporting the shipped 15 would state a number nobody wrote.The grant, measured
The
mcprole gainsGRANT UPDATE (email_cooldown_minutes) ON config.config_notification. This does widenmcpinto a table holding bearer secrets — the SMTP password blob, the Teams/Slack/generic webhook URLs, the PagerDuty routing key — so it is column-level on that one column, the same shape as theconfig_servicebeacon grant beside it.A missing SELECT and a missing UPDATE raise the identical
42501: permission denied for table config_notification, so the grants were isolated against a live store rather than reasoned about:42501GRANT UPDATE (email_cooldown_minutes)42501, the write still succeeds — so UPDATE is the privilege doing the work, not an ambient SELECT42501againSo the read side needs nothing:
email_cooldown_minutesis already in the section-6 non-secret carve. Writes tosmtp_encrypted_password,slack_urland even the non-secret siblingsmtp_hostall stay42501, and the secret columns stay unreadable. The reload beacon is already covered —config_notificationcarriestrg_bump_notification, which bumpsconfig_service.config_versionas the current role, and section 8 already grantsmcpthose two columns.Darling/tools/provision-roles.sqlneeds nothing: it provisions nomcprole, the network MCP endpoint being managed-mode only.DarlingSecuritySplitLiveTestsnow performs that write as themcprole and asserts the three sibling denials, so the grant's narrowness is proven rather than described.The read cannot widen
config_notificationis where #2293/#2298 played out: the MCP host's whole-row read was removed, not narrowed, because column-level denial answers for the whole table and skipping one denied column just moved the error to the next. This read is the shape that survives that — one column the carve explicitly grants.McpConfigReadAvoidsSecretColumnsTestsgains the mirror of its existing pin: the privileged read must still name secrets (so the host's skip stays justified), and this MCP read must name none, with the secret set derived from the ACL so a reclassification fails on the day it happens.The 120-minute ceiling stays, and why
DarlingAlertSettingsclamps1..120, and the MCP write bound must equal the engine clamp exactly — the parityFileGrowthWriteBounds_MatchTheEngineClampsandAgConnectionAndBlockingWaitWriteBounds_MatchTheEngineClampsalready hold, for the reason that a wider bound lets the tool ACCEPT a value the engine silently rewrites on read, presenting as the setting not sticking with nothing saying no. So raising the ceiling is a behaviour change across four clamp sites in both SKUs, not a bound edit.It should not be raised. The cooldown is one global number applied to every fingerprint on every server, while the need it is being asked to serve is one recurring signature legitimately open for hours. Stretching it silences everything else at the same cadence, including a genuinely new critical alert.
create_mute_rulealready does that job properly: scoped (server, metric, database, query text, wait type, job name), expiring, listed byget_mute_rules, and it still logs the alert — where a multi-hour cooldown is an unscoped, undisclosed suppression that no tool reports.update_alert_settings' description now points there.Lite
Lite runs the same shared throttle —
WebhookAlertServiceandEmailSendCoreboth handIncidentCooldownthe same value throughAppAlertSettings— so Lite'sget_alert_settingsreports the same key.McpAlertSettingsKeyTestsderives Darling's shape from source and compares both directions, so this is required for parity, not optional. Lite has noupdate_alert_settings; the stored spelling stays insettings.jsonand the Settings window.No migration rung
The column already exists, and grants are applied by
EnsureProvisionedAsyncon every managed start (idempotent and self-healing, after migration) rather than by a rung.StorageVersion.SchemaVersionstays 117, so this does not contend with #3328's 118.Verification
Darling.Teststargetsnet10.0-windowsand cannot run on macOS, so the actual test sources were compiled into anet10.0console harness against a live PostgreSQL 18 container: 210 passed, 0 failed, 1 skipped (the TimescaleDB-gated hypertable test). That coversDarlingMcpAlertToolsTestsboth classes,DarlingSecuritySplitLiveTests,McpConfigReadAvoidsSecretColumnsTests, and the whole-tree guardsDocCommentHygieneTests,McpReadCommandTimeoutTests,CommandDeadlineScannerAdoptionTests,TsqlConventionGuardTests,PgReadKindDisciplineTests,StoreSqlClockDisciplineTestsandReadmeDerivedCountPinTests.Folding the whole-tree guards in earned its keep:
McpReadCommandTimeoutTestsfailed on the first cut. The three-argumentnew NpgsqlCommand(sql, connection, transaction)is the monitored-TARGET shape in its allowlist (the HypoPG experiment's, bounded by a server-sideSET LOCAL), so a store command written that way reads as unclassified. Fixed by assigning the transaction rather than widening an allowlist whose whole job is to stop a target command taking a store bound.Eleven mutations were run to prove the pins discriminate, each red in the pin that owns the property: dropping the payload key, widening the write bound to 240, naming
slack_urlin the MCP read, removing themcpgrant (live42501), disabling the two-names guard, skipping the notification table in the write loop, restoring the three-argument constructor, emptying the notification SELECT list, malforming the sample-config comment, and downgrading the snapshot toREAD COMMITTED. An eleventh — splitting the combined read back into two — is a compile error rather than a test failure, which is the stronger guarantee. The last one exists because the collision test compares column counts, and a count comparison is satisfied by two empty sets — so each plane is now asserted non-empty before the counts are compared. Lite's parity test cannot run here; it was checked by an independent re-derivation of the same brace walk over both payload initializers, which was itself falsified by removing Lite's key.Two arms deliberately not covered live
The
unavailablearm of the notification read, and its rollback, are not exercised by a live test. Reaching either means deleting theconfig_notificationid=1 row, which holds the SMTP password blob, the webhook URLs and the PagerDuty routing key — so the test would be capturing and rewriting credentials, and a mid-test failure on a shared store would destroy the notification configuration. The arm mirrors the settings-row arm directly above it, which is covered, and the ordering makes a partial application impossible either way: if the settings row is missing the loop returns before touching notification, and if the notification row is missing the settings UPDATE is inside the transaction being rolled back. Covering it properly wants a dedicated throwaway database rather than a delete-and-restore against a live one; say the word and I'll add one.Lite.Testsand the Windows suites remain CI's to arbitrate.