Skip to content

Add get_store_host MCP read for the store host profile (#4214) - #4282

Merged
erikdarlingdata merged 19 commits into
devfrom
feature/4214-store-host-read
Sep 25, 2026
Merged

erikdarlingdata merged 19 commits into
devfrom
feature/4214-store-host-read

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Part of #4214.

Why

Part 1 (#4271, merged) built the store host profile model, the --check-settings CLI verb and the
--validate-config registry fix. Nothing could reach that profile except a shell on the store's own host: no
MCP read, no web panel. This PR adds the read half (get_store_host) and the web Store host panel. Cloud
identity through IMDS (#4214's third piece) is a later, separate lane.

What changes

  • get_store_host (Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs): calls
    DarlingStoreHostProfile.GatherAsync directly (part 1's own orchestration - no second copy, ruling 1).
    Returns platform/RAM/data-volume host facts, PostgreSQL/TimescaleDB store facts, and a verdict
    (matches/stale_after_hardware_change/operator_override/not_managed) per sizing-relevant setting, plus
    an any_stale summary flag. No parameters - a snapshot, store-level like get_store_metrics, not a block on
    it (ruling 1). Reads as the least-privilege mcp role (ruling 2); the managed conf-file attribution is a
    local disk read by the service process itself, same as part 1.
  • Its own deadline class: ServiceCommandDeadlines.McpStoreHostProfileSeconds (25s), an outer linked CTS
    around GatherAsync. Updated SerialLoopStoreSizeSourceTests' Store host visibility: a cross-platform host profile, a --check-settings verb, and a get_store_host read, so "is this store sized right" has an answer without shell access #4214 allow-list entry for the second caller.
  • Registration: PostgresConfig registered as an MCP-host DI singleton; DarlingMcpStoreHostTools
    registered via .WithGeminiCompatibleTools<T>().
  • Instructions census: 159->160 tools, 71->72 Darling-unique. Added get_store_host to
    KnownLiteMissingMcpTools (Lite has no managed PostgreSQL store - a SKU boundary).
  • Catalog + /api/read: the R(CatOverview, ...) catalog row and the dispatch row in
    DarlingWebEndpoints.cs. BuildReadDispatch/ConfigurePipeline/MapAll each gained an optional
    PostgresConfig? postgresConfig = null parameter (mirroring the existing logger closure pattern for
    get_sweep_reports). Updated the source-pin tests that literal-matched the old call text
    (FleetSweepWebFeedTests, SharedBaselineCacheTests, and a third one this lane found -
    DarlingMcpFleetSweepToolsTests.TheMirrorsLoggerSeat_..., which still pinned BuildReadDispatch(logger)
    after the signature grew a second argument).
  • README.md / llms.txt: bumped the Darling tool census (159->160 in both places) - the instructions string
    was updated but these two files were not, failing CrossAppMcpToolInventoryPinTests in CI.
  • McpToolsListBudget/DarlingMcpStoreHostTools.txt: the new tool's served-block pin (510 chars, no
    parameters). McpToolsListBudgetTests.TotalCeilingBytes raised by the tool's own +616 bytes (merged with
    origin/dev's own accumulated raises from other lanes; final value is the sum, not two deltas added by hand).
  • DarlingMcpStoreHostBudgetLiveTests: proves the payload stays under McpResponseBudget.DefaultBytes -
    this tool's shape is fixed (host/store facts plus one row per setting, 8 today), not row-scaling like the
    other MCP read tools have no default response-size budget: at default arguments 12 tools return >50 KB and 3 return >100 KB for one server, more than an agent client's per-result cap #4198 tools, so one live call in the not_managed shape (the longer of the two source strings) is the
    whole proof.
  • DarlingMcpStoreHostToolsLiveTests: a live test that calls get_store_host through the SAME
    least-privilege roles it runs under in production - mcp (the MCP host's own connection role) and viewer
    (the web /api/read row's connection role, TryBuildViewerConnectionStringFromStoredCredential) - rather
    than the rig superuser every other Store host visibility: a cross-platform host profile, a --check-settings verb, and a get_store_host read, so "is this store sized right" has an answer without shell access #4214 part 2 test uses. Proves all four verdicts are reachable under each
    role. No permission gap found: pg_extension/pg_stat_database/pg_settings carry no Darling-authored
    grant anywhere in DarlingManagedRoles - PostgreSQL's own PUBLIC defaults are what let mcp/viewer read
    them, and this test is what actually proves that rather than trusting it. The one grant that does matter
    (SELECT on collect, for timescaledb_information.chunks visibility) already exists in production
    provisioning and is mirrored in the test's own disposable roles.
  • Web Store host panel (ruling 5, placement changed from H2a's original "What remains" note): a "Store
    host" section on the Fleet Sweeps page (wwwroot/js/pages/sweeps.js), below Watch items - not a new Fleet
    page card, which is what the earlier draft of this PR proposed before finding no existing "store metrics
    panel" page to extend. Reads get_store_host through /api/read (readTool, the page's own read
    pattern), renders host facts plus a per-setting table (current/derived/source/verdict), with every
    stale-* verdict highlighted via the page's existing sev-Warning/sev-Healthy vocabulary. Read-only, like
    the rest of the page (no apiSend call - the CLI verb and the companion sizing issue own writes). Uses the
    page's own local table()/cellText() helpers, no new table implementation. FleetSweepWebFeedTests
    gained a source pin (no JS runner in this pipeline) for the section title, the read call, and the
    startsWith("stale") highlight predicate.
  • Ruling 7 fix (kept from the earlier draft): DarlingCliCommandsHostCheckTests. CheckSettingsAsync_ManagedStoreWithAStaleBlock_ReturnsStaleSettingsExitCode_Gated now creates and drops its
    own darling database and uses a throwaway temp conf directory instead of the rig's real one. Confirmed
    live: it was failing with StoreUnreachable on a hand-built rig that never has a darling database.

Confirmed root cause (ruling 7)

DarlingManagedPostgres.DatabaseName = "darling" and UserName = "darling" are hardcoded constants (not read
from config) that TryBuildConnectionStringFromStoredCredential always targets. A rig built per the lane
rig-setup steps only creates darlingtest (for the suite) and probe (for hand-run SQL) - never darling -
so the old test's "managed" connection attempt failed before it ever reached a settings verdict.

Test plan

  • dotnet build Darling/Darling.Tests/Darling.Tests.csproj - 0 Warning(s), 0 Error(s).
  • dotnet build Lite.Tests/Lite.Tests.csproj - 0 Warning(s), 0 Error(s).
  • DarlingCliCommandsHostCheckTests live against the rig (rig-th, port 55983, started in the background,
    "ready to accept connections" confirmed, darlingtest/probe dropped and recreated first): 7 total, 0
    failed, 0 skipped.
  • DarlingMcpStoreHostToolsLiveTests (new, the mcp/viewer least-privilege role proof): 1 total, 0 failed,
    run twice for stability; confirmed the disposable host_mcp_test/host_viewer_test roles are dropped after
    each run.
  • DarlingMcpStoreHostBudgetLiveTests (new, the response-size proof): 1 total, 0 failed.
  • McpToolsListBudgetTests (the tools/list budget pin + layout + total-ceiling checks): 5 total, 0 failed.
  • CrossAppMcpToolInventoryPinTests (Lite.Tests, run once since this branch changed it): 6 total, 0
    failed.
  • FleetSweepWebFeedTests + DarlingWebAssetsTests (the web panel's source pins): 59 total, 0 failed.
  • git merge origin/dev - one conflict (McpToolsListBudgetTests.TotalCeilingBytes, both sides had moved
    it), resolved by summing dev's accumulated raise with this PR's own delta. Kept both change-log comment
    blocks.
  • Full Darling suite, once, against the rig after the merge (fresh darlingtest): 13,992 total, 0
    errors, 4 failed, 29 skipped, 1 not run
    (765s). Of the 4:
    • LivePostgresCollectionHygieneTests.EveryClassUsingTheSharedStore_IsSerializedOrDocumentsWhyNot - mine:
      the new DarlingMcpStoreHostBudgetLiveTests reached the shared store without
      [Collection("live-postgres")]. Fixed (added the attribute) and re-confirmed passing.
    • DarlingMcpFleetSweepToolsTests.TheMirrorsLoggerSeat_TakesTheCapturedServiceLogger_NotAHardcodedNull -
      mine: a third source pin (beside the two H2a already fixed) still expected the old
      BuildReadDispatch(logger) call text. Fixed and re-confirmed passing.
    • TrendPayloadBudgetLiveTests.EveryDefaultAnswer_StaysNearTheBudget_AndTheLargestAnswerStaysUnderTheCap -
      failed with "Exception while reading from stream" reading get_file_io_trend, code this PR never
      touches. Read as a transient stream error against the shared rig after a 13,992-test, 765-second run, not
      a defect in this diff.
    • CaptureDownChunkOrderTests.TheShippedRead_ExecutesOnlyTheNewestChunk_AndTheNewestRunDecides_ AgainstDevPostgres - failed on TimescaleDB chunk-ordering logic this PR never touches.
  • Round-1 security review (comment 5833738007): every item landed across the fix passes (comments
    5834755849 and 5835678714), ending at 682b465a.
  • Rig pass (comment 5836188434): git merge origin/dev again, one conflict in
    McpToolsListBudgetTests.TotalCeilingBytes, set to the 175,170 bytes the test measured, both change-log
    comments kept (9e3563d1). Live classes on a UTC rig, all 0 failed: DarlingMcpStoreHostToolsLiveTests (1
    live), DarlingMcpStoreHostBudgetLiveTests (1 live), DarlingCliCommandsHostCheckTests (7 live),
    StoreHostProfileCacheTests (4), McpToolsListBudgetTests (5), McpServiceParameterDiSeatCensusTests (1),
    FleetSweepWebFeedTests (39), DarlingWebAssetsTests (21).
  • Full Darling suite, once, after that merge: 14,076 total, 0 errors, 0 failed, 29 skipped, 1 not run.
    TrendPayloadBudgetLiveTests and CaptureDownChunkOrderTests, the two unexplained failures above, passed.
  • CI on 9e3563d1 decides.

What remains

Double-check requested (from the earlier draft, still open)

  • The PostgresConfig? postgresConfig = null threading through BuildReadDispatch -> MapAll and
    ConfigurePipeline - confirm the optional-parameter, closure-based approach is the right shape rather than
    widening the ReadToolHandler delegate itself.
  • The McpStoreHostProfileSeconds = CliStoreReadSeconds * 2 + 5 = 25s derivation.
  • The web panel's placement (Sweeps page, not a new Fleet card) and whether sev-Warning is the right
    severity for stale_after_hardware_change (versus sev-Critical - it is a sizing drift, not an outage).

CHANGELOG entry

SECTION: Added
ENTRY:

🤖 Generated with Claude Code

https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ

erikdarlingdata and others added 8 commits September 25, 2026 08:47
get_store_host reuses part 1's DarlingStoreHostProfile.GatherAsync (#4271) to
serve the host/store/settings profile over MCP and /api/read, with its own
enclosing deadline (store-size reads scale with the store, #3199) and a fixed
#4271 CLI test that assumed a bootstrapped rig's own postgresql.conf and
"darling" database rather than building its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
get_store_host is the 160th Darling MCP tool; DarlingMcpInstructions.cs
already carried this bump but README.md and llms.txt did not, failing
CrossAppMcpToolInventoryPinTests.RootReadmeDarlingToolCensus_MatchesTheScannedInventory
and LlmsTxtToolCensus_SpansTheTwoCurrentEditions in CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…vilege roles

DarlingMcpStoreHostToolsLiveTests calls DarlingMcpStoreHostTools.GetStoreHost
through the SAME least-privilege roles it runs under in production (mcp for
the MCP host, viewer for the web /api/read row), not the rig superuser every
other #4214 part 2 test uses. Proves all four verdicts (matches,
stale_after_hardware_change, operator_override, not_managed) are reachable
under each role:

- work_mem: a per-role ALTER ROLE ... SET default applied before the role's
  first connection, matched against this host's own DeriveMemorySettings
  figure -> matches (shared_buffers/max_connections are PGC_POSTMASTER and
  the live rig would need a restart to move them, so work_mem, PGC_USERSET,
  is the one setting a live value can be set for without touching the rig's
  real conf or going through postgresql.auto.conf, which would misattribute
  to operator_override).
- max_connections: a managed-block assignment of 100 against the
  TargetMaxConnections=200 constant -> stale_after_hardware_change.
- maintenance_work_mem: left unassigned in the fake managed conf ->
  operator_override (ClassifyVerdict's default arm).
- A second gather with Managed=false -> not_managed for every setting.

No permission gap found: pg_extension/pg_stat_database/pg_settings carry no
Darling-authored grant anywhere in DarlingManagedRoles, and this proves
PostgreSQL's own PUBLIC defaults are what let mcp/viewer read them; the one
grant that matters (SELECT on collect, for timescaledb_information.chunks
visibility) already exists in production provisioning and is mirrored here.

Ran twice against the rig-th rig (port 55983): 1 total, 0 failed both times;
confirmed host_mcp_test/host_viewer_test roles are dropped after each run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
McpToolsListBudget/DarlingMcpStoreHostTools.txt pins the new tool's
served block (510 chars) and McpToolsListBudgetTests.TotalCeilingBytes
moves 173,157 -> 173,773 (+616, the tool's own JSON scaffolding
included) to match: get_store_host has no parameters, so this is the
tool's whole entry.

DarlingMcpStoreHostBudgetLiveTests proves the response-size half:
get_store_host's payload is a fixed shape (host/store facts plus one
row per sizing-relevant setting, 8 today), not row-scaling like the
other #4198 tools, so one live call in the not_managed shape (the
longer of the two source strings) is the whole proof. Passed at well
under McpResponseBudget.DefaultBytes (32 KB) against the rig-th rig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Adds a "Store host" section to the Fleet Sweeps page, below Watch
items: reads get_store_host through /api/read (readTool, the page's
existing read pattern - no raw fetch, no second copy of the verdict
math #4214 part 1 already owns) and renders host facts (platform,
CPUs, RAM, data volume, managed flag, PostgreSQL/TimescaleDB facts)
plus a per-setting table (current/derived/source/verdict), with every
stale-* verdict highlighted using the page's existing sev-Warning/
sev-Healthy vocabulary. Read-only, like the rest of this page (no
apiSend call) - the CLI --check-settings verb and the companion
sizing issue own any write.

Uses the page's own local table()/cellText() helpers (no new table
implementation) and four new util.js imports (fmtMb, fmtPct, fmtBool,
fmtText) already used elsewhere in the app.

FleetSweepWebFeedTests gains a source pin (no JS runner in this
pipeline) proving the section title, the get_store_host read call,
and the startsWith("stale") highlight predicate. Verified sev-Warning/
sev-Healthy are real table.data td rules in app.css, and node -c
confirms the edited file parses. FleetSweepWebFeedTests (59) and
DarlingWebAssetsTests all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…t-read

# Conflicts:
#	Darling/Darling.Tests/McpToolsListBudgetTests.cs
Full suite (13,992 tests) surfaced two defects caused by this branch:

- DarlingMcpStoreHostBudgetLiveTests reached the shared DARLING_TEST_PG
  store without [Collection("live-postgres")], failing
  LivePostgresCollectionHygieneTests. Added the attribute, matching
  its sibling DarlingMcpStoreHostToolsLiveTests.
- DarlingMcpFleetSweepToolsTests.TheMirrorsLoggerSeat_... pinned the
  old BuildReadDispatch(logger) call-site text; BuildReadDispatch now
  also threads postgresConfig (get_store_host's config seat) per this
  PR's earlier commit, so the literal moved to
  BuildReadDispatch(logger, postgresConfig) - the same kind of pin
  update already made for FleetSweepWebFeedTests/SharedBaselineCacheTests,
  missed for this third site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…t-read

McpToolsListBudgetTests: keep every change-log line. TotalCeilingBytes
re-measured on the merged tree: dev after #4272 and #4273 (174,373) plus
get_store_host's 616 bytes = 174,989.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Round-1 security review at f71f154

Scope: the diff at f71f154 and the part-1 code that it calls (DarlingStoreHostProfile and the conf reader in DarlingManagedPostgres). I also read the web seat gate, the MCP schema binding and the SPA poll. This was a read-only review. I ran nothing against a live store. File paths are under Darling/PerformanceMonitor.Darling.Service/ unless a path says otherwise.

Result: no High findings. There are two Medium findings, four Low findings, and one pre-existing Low finding that this PR does not widen. We recommend fixing both Medium findings in this PR, because each fix is small.

Answers to the six questions

  1. Leaks. One field leaks host detail: settings[].source carries absolute conf-file paths (Medium 1). No field carries a password, a token or a connection string. The other fields are numbers, booleans, versions and fixed strings (DarlingMcpStoreHostTools.cs:92-139). Nothing filters by seat, so every seat sees the same payload. On the web, the shared-token seat, an OIDC admin seat and an OIDC viewer seat all get it. The read-only gate refuses only unsafe methods, and this read is a GET (Hosting/DarlingWebSeat.cs:110-123, Mcp/DarlingWebHostService.cs:1080-1092). On MCP, every client that passes the gate of the host gets it. I found no per-client role on the MCP host, only one bearer token (Mcp/DarlingMcpHostService.cs:1010-1035). Error bodies are a separate, pre-existing conduit (Low 7).
  2. DOM. Yes, every payload value reaches the DOM as text. cellText uses the text prop of el() (sweeps.js:508-510). el() writes that prop through textContent and throws on html (util.js:26-37). Plain strings become text nodes (util.js:59-70). errorStrip and emptyStrip pass the message as a text child (util.js:128-133). The only class values are the constant mono and the three constants from verdictSev (sweeps.js:490-496). No attribute is built from payload text. The new code has no innerHTML.
  3. PostgresConfig. Not today. The payload is an explicit projection (DarlingMcpStoreHostTools.cs:92-139). GatherAsync reads only Managed and DataDirectory (DarlingStoreHostProfile.cs:322-323, :623, :661-680). The error path sends only ex.Message (PerformanceMonitor.Common/Mcp/McpHelpers.cs:623-635). The data source exists before the call, so no connection-string parse error can happen here. The gather catches the file exceptions that can carry a path (DarlingStoreHostProfile.cs:232-242, :290-310, :512-520, DarlingManagedPostgres.cs:1806-1815). The tool logs nothing. The web backstop logs only exceptions that escape the tool, and this tool catches all of them (DarlingWebEndpoints.cs:319-328). There are two caveats. DataDirectory reaches the body through source (Medium 1). The object that holds the owner secret is now injectable (Low 3).
  4. Least privilege. GatherAsync needs nothing beyond the mcp and viewer grants plus the PUBLIC defaults of PostgreSQL. Its statements are SHOW server_version, pg_extension, pg_database_size(current_database()), pg_stat_database, timescaledb_information.chunks with pg_total_relation_size, and eight named rows of pg_settings. None of the eight settings is superuser-only (DarlingStoreHostProfile.cs:327-362, :609). The conf-file attribution runs as the OS account of the service and needs no PostgreSQL right (:486-526). This PR adds no GRANT. It touches no migration, no provisioning SQL and no DarlingManagedRoles code. The live test grants only USAGE and SELECT on collect (Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs:158-166). One gap: the test does not prove the store-fact reads (Low 5).
  5. Throttle. There is no throttle and no cache (Medium 2).
  6. Other. The diff has no process execution. It adds no caller input: the tool takes no parameters, and the web lambda reads no query keys (DarlingWebEndpoints.cs:2950). Remote callers can now trigger the conf-file reads (Medium 2, Low 4). Auth is unchanged, because the route and the tool sit behind the existing gates. Low 3, Low 4 and Low 6 cover the rest.

Findings

Medium 1: absolute conf-file paths reach every remote seat

  • Where: ClassifyVerdict builds source from attribution.File (DarlingStoreHostProfile.cs:538, :544). The include branch passes the path of an included file through the same field (:509). ConfAssignment.File is a full path (DarlingManagedPostgres.cs:1767-1769, :1854). The tool serializes it (DarlingMcpStoreHostTools.cs:135), and the Sweeps page shows it (sweeps.js:456).
  • Failure: on a managed store, each row reads "managed block (full path of postgresql.conf:line)". The default data directory is under %ProgramData% and names no person. But postgres.dataDirectory can point anywhere (DarlingManagedPostgres.cs:641-645), and an include line can point at any local file. If either one is under a user profile, an OIDC viewer seat learns that account name. The path also shows where the store credential files are, because they sit in the parent of the data directory (DarlingManagedPostgres.cs:648-660). Part 1 printed this only on a local console. I found no other MCP or web read that exposes the data directory.
  • Fix: do not send directories on MCP or the web. For a file inside the data directory, send the path relative to it (Path.GetRelativePath(dataDirectory, file), for example postgresql.conf:812). For a file outside it, send only Path.GetFileName(file). Keep the full path in the local --check-settings output. Add a test that the served source never contains the data directory and never holds a rooted path.

Medium 2: an uncached, unthrottled store-scaling probe that the UI runs again every minute

  • Where: every call runs StoreSizeSql (pg_database_size) and UncompressedChunkSizeSql (pg_total_relation_size over every uncompressed chunk) live (DarlingStoreHostProfile.cs:338, :357-362, :666). Part 1 kept these reads off every recurring path because of their cost. Its own comment cites 3,177 ms on a 225 GiB store (DarlingStoreHostProfile.cs:688-697).
  • The Sweeps page renders again on the 60-second poll (wwwroot/js/app.js:49, :300-322, :348-350, :129). So each visible Sweeps tab runs both reads once a minute. One wall display does this all day.
  • The new name is in the read allowlist that saved views validate against (DarlingWebEndpoints.cs:1549, :2045). A saved view can hold 48 panels (DarlingWebEndpoints.cs:1332). A read panel calls /api/read/<name> from the browser of each viewer (wwwroot/js/panels.js:75). So one view can run 48 probes per viewer per render. The MCP server also lists Custom Views authoring among its writes.
  • Nothing cancels an abandoned call. The tool uses its own 25-second token (DarlingMcpStoreHostTools.cs:82, ServiceCommandDeadlines.cs:479). The web dispatch does not pass RequestAborted (DarlingWebEndpoints.cs:2950), and MCP clients cannot cancel.
  • I found no request rate limiter or concurrency cap on either host. The role login pools hold 24 connections (DarlingStoreLogins.cs:85, :254).
  • Failure: a few open tabs, one crowded view or one scripted loop keeps pg_database_size walking the files of the store back to back. That competes with ingest for disk I/O. Enough concurrent calls fill the viewer or mcp pool, and other reads on that surface then wait and time out. This needs an authenticated web seat or the MCP token. A read-only seat is enough.
  • Fix: cache the gathered profile in the process for a short time, for example 60 seconds to 5 minutes. Put the gather behind a single-flight SemaphoreSlim that both hosts share. The tool describes itself as a snapshot, so a cached answer is honest. Add a gathered_at field so that callers see its age. Another option is to report the size from the hourly self-metrics figure (StoreSelfMetrics.LatestStoreSizeSql) instead of a live pg_database_size. In sweeps.js, load the Store host section on navigation only, not on the poll, or put it behind a Refresh button.

Low 3: the whole PostgresConfig, owner secret included, is now injectable

  • Where: Mcp/DarlingMcpHostService.cs:489 registers config.Postgres as a DI singleton. DarlingWebEndpoints.cs:2950 captures it in the dispatch closure. On a bring-your-own store, ConnectionString holds the resolved owner connection string with its password (DarlingConfig.cs:324).
  • Failure: nothing serializes or logs it today, so this is not a leak now. But any future [McpServerTool] can declare a PostgresConfig parameter and receive the owner credential. PostgresConfig is a JSON DTO. If a later change serializes it or passes it to a structured log, the owner password goes on the wire.
  • Fix: at host start, build a trimmed copy with only the two properties that the gather reads: new PostgresConfig { Managed = src.Managed, DataDirectory = src.DataDirectory }. Register and capture that copy instead. No signature changes.

Low 4: no test pins that PostgresConfig stays out of the served schema

  • Where: the schema exclusion depends only on the DI registration at Mcp/DarlingMcpHostService.cs:489 (PerformanceMonitor.Common/Mcp/McpSchemaCompat.cs:96-110). The tools/list budget harness registers every complex parameter type as a service by a type heuristic (Darling.Tests/McpToolsListBudgetTests.cs:540-550, :692-703). Both new live tests call the tool directly with a hand-built config (DarlingMcpStoreHostToolsLiveTests.cs:89-90, DarlingMcpStoreHostBudgetLiveTests.cs:53).
  • Failure: if someone drops line 489 or puts it behind a condition, the SDK serves postgresConfig as a client argument, and no test fails. Then an MCP client can send managed: true with a UNC dataDirectory. The include resolver refuses UNC paths (DarlingManagedPostgres.cs:1859-1874), but nothing checks the root data directory (DarlingManagedPostgres.cs:641-645). So the service opens postgresql.conf on a remote share as its own account. That sends an NTLM login attempt for the service account to the share host, where it can be relayed or cracked.
  • Fix: add a census test that every complex parameter type of a registered tool has an AddSingleton in DarlingMcpHostService.cs. The budget harness already reads that file by regex. Also refuse a UNC root in the profile reader, the same way TryResolveConfPath refuses a UNC include.

Low 5: the least-privilege proof does not cover the store-fact reads

  • Where: GatherStoreFactsAsync swallows PostgresException for the TimescaleDB version, the store size and the uncompressed-chunk read (DarlingStoreHostProfile.cs:378, :391, :423). The live test checks only the verdicts and any_stale (DarlingMcpStoreHostToolsLiveTests.cs:105-117).
  • Failure: if the mcp or viewer role lacks a right for pg_database_size or timescaledb_information.chunks, the tool reports a null size or "0 chunks, 0 MB". The test still passes. A silent zero reads as "nothing uncompressed", which is the healthy answer. This is a gap in the proof, not an exploit.
  • Fix: in the live test, check for each role that store.size_bytes and store.timescale_version are not null. Also check that uncompressed_chunk_count equals the count that the owner sees on the same rig.

Low 6: hardcoded password for the test login roles

  • Where: RolePassword at DarlingMcpStoreHostToolsLiveTests.cs:47. Two LOGIN roles use it, and each role gets SELECT on every table in collect (:158-166).
  • Failure: the password is public in this repository. Cleanup runs in finally, but a cleanup failure after a failed body is swallowed (Darling.Tests/LiveStoreCleanup.cs:78), and a killed run skips cleanup. If a role survives on a rig whose port other machines can reach, anyone can log in and read all collected data on that rig. DarlingSecuritySplitLiveTests uses the same pattern.
  • Fix: generate the password for each run with Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).

Low 7 (pre-existing, not widened here): error bodies carry ex.Message

  • Where: PerformanceMonitor.Common/Mcp/McpHelpers.cs:623-635, called at DarlingMcpStoreHostTools.cs:143.
  • Failure: an Npgsql connect failure usually names the host and port of the store, and a PostgreSQL login failure names the role. Every tool on both surfaces shares this path. This PR adds one more caller. It adds no new kind of message, because the gather catches its own file exceptions.
  • Fix: this is surface-wide, so we recommend a separate issue. One option is to map NpgsqlException to a fixed sentence in FormatError and log the detail on the server.

Checked and clean

  • MCP binding: postgresConfig is excluded from the served schema and comes from DI, not from the client (McpSchemaCompat.cs:96-110, :163-173, Mcp/DarlingMcpHostService.cs:489).
  • The web route ignores the query string, and the SPA sends no parameters (DarlingWebEndpoints.cs:2950, sweeps.js:427).
  • The triage runner builds the dispatch without a config. If it ever calls this read, it gets the unavailable answer and runs no probe (DarlingTriageEndpoint.cs:563, DarlingMcpStoreHostTools.cs:70-78).
  • SQL: every statement is a constant. The pg_settings read binds its array as a parameter (DarlingStoreHostProfile.cs:609-612). The chunk query quotes identifiers with the %I format specifier (:359).
  • File reads: fixed file names in the data directory, plus the include chain that the conf names. The chain stops at 64 files and a depth cap, and UNC includes are refused (DarlingManagedPostgres.cs:1785-1874). The conf values (RawValue) never reach the wire. Only the file and the line do.
  • The gather starts no process. Host facts come from GlobalMemoryStatusEx, /proc/meminfo and the cgroup files (DarlingStoreHostProfile.cs:250-285).
  • Auth: the PR adds no route outside the existing gates and no auth code.
  • Deadline: McpStoreHostProfileSeconds is 25 seconds, which covers two 10-second store-scaling statements plus a margin (ServiceCommandDeadlines.cs:194, :479). On a managed store, the role statement_timeout still bounds each statement.
  • Accepted exposure, not a finding: CPU count, RAM, disk sizes, the filesystem and the versions go to every seat. That is the purpose of the tool. postgres_version is the raw SHOW server_version, which can carry a distro build suffix. On an OS that is neither Windows nor Linux, platform falls back to RuntimeInformation.OSDescription (DarlingStoreHostProfile.cs:279-284).

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Round-1 fix lane: no code landed, full plan below for the next lane

This session hit the context watchdog's hard block (past ~300k transcript tokens) during
orientation and design, before writing any code. The worktree is confirmed clean at HEAD
f71f154 on feature/4214-store-host-read (matches the brief) — nothing was committed or
pushed, so there is nothing to revert. PR #4282 was confirmed OPEN/draft at that same head
before posting this comment.

All of the time went into reading the affected files and getting a stronger reviewer's
sign-off on the design for item 2 (the cache), which has a real correctness trap. That
design is now settled — see below — so the next lane should be able to execute directly
without redoing the investigation.

Plan for the next lane, in order (validated by an advisor pass over the full orientation)

Item 1 (Medium 1, conf paths). HostSettingProfile (DarlingStoreHostProfile.cs:101-108) is
a positional record struct; add trailing string? SourceFile = null, int SourceLine = 0 (default
values so the 3 ctor call sites at lines 632, 644, 653 and the 2 in
Darling.Tests/StartupHostProfileLogTests.cs:54-55 don't all need edits — only line 653, the
managed-attribution branch, passes attribution.File, attribution.Line). Keep SourceDescription
(full path) untouched for the CLI/local surfaces (FormatProfileText, FormatStartupProfileText:851,
DarlingWorker.cs:1696 all keep reading it as-is). Add one new internal static formatter in
DarlingStoreHostProfile.cs used only by the MCP tool:

  • SourceFile is null -> return SourceDescription verbatim (the not-managed/no-block/unreadable
    strings never carry a path — pg_settings.source is a kind word like "configuration file", not
    a path — so nothing to redact).
  • SourceFile is not null -> kind text from Verdict (Matches/StaleAfterHardwareChange ->
    "managed block", OperatorOverride -> "operator override"; File is only ever non-null in those
    two verdicts) + a sanitized path built as: Path.GetFullPath both the data directory and the
    file, ordinal-ignore-case StartsWith(dataDir + separator) to test containment, THEN
    Path.GetRelativePath only when inside; when outside, Path.GetFileName(file) ONLY — do not
    call GetRelativePath for an outside file, it returns a leading ..\... that still leaks the
    parent tree.
  • Resolve the data directory for this formatting step via DarlingManagedPostgres.ResolveDataDirectory(postgresConfig)
    in DarlingMcpStoreHostTools.GetStoreHost, called once, reused for every settings row.
  • Wire it into DarlingMcpStoreHostTools.cs:135 (source = ...) in place of s.SourceDescription.
  • Unit-test the 3 shapes as pure-function tests (inside data dir -> relative path with no leading
    ../no drive root; outside -> bare file name only, asserting the result contains neither the
    data directory string nor a rooted path; BYO/no-file -> unchanged SourceDescription). Revert-prove
    by reverting only the formatter call at DarlingMcpStoreHostTools.cs:135 back to s.SourceDescription
    and confirming the new tests fail.

Item 2 (Medium 2, cost) — the settled cache design. Do NOT use a bare static field inside the
tool: DarlingMcpStoreHostToolsLiveTests.cs calls GetStoreHost 4 times inside the same run (2
roles x managed/BYO) and asserts different payloads each time, which a process-global implicit
cache would break.

  • New sealed class (e.g. StoreHostProfileCache, new file in
    PerformanceMonitor.Darling.Service): holds one cached (HostProfile Profile, DateTime GatheredAtUtc),
    one SemaphoreSlim(1,1), ctor (TimeSpan ttl, Func<DateTime>? utcNow = null), method
    GetOrGatherAsync(Func<CancellationToken, Task<HostProfile>> gather, CancellationToken ct). Fast
    path: fresh cache hit, no lock. Miss path: WaitAsync(ct), RE-CHECK the cache (a queued waiter may
    find the winner already refreshed it), gather, store, release. An exception from gather must
    propagate without ever assigning the cache fields (never cache a failure).
  • One process-wide internal static readonly StoreHostProfileCache Shared = new(TimeSpan.FromMinutes(5));
    but GetStoreHost takes the cache AS A PARAMETER (DI-resolved), not as a static field read inside
    the method body: MCP host registers Shared via AddSingleton at DarlingMcpHostService.cs (near
    line 489, alongside the item-3 fix below — this doubles as a new subject for item 4's census); the
    web dispatch closure at DarlingWebEndpoints.cs:2950 passes Shared too. Tests construct their OWN
    fresh new StoreHostProfileCache(...) per call/fixture, which keeps DarlingMcpStoreHostToolsLiveTests
    and DarlingMcpStoreHostBudgetLiveTests uncontaminated with zero changes to their existing call
    shape (GetStoreHost just gets one more argument).
  • Move postgres.OpenConnectionAsync (currently unconditional near DarlingMcpStoreHostTools.cs:84)
    INSIDE the gather delegate passed to GetOrGatherAsync. A cache HIT must open no connection —
    pool exhaustion under repeated polling is the actual harm the review flagged, and a cached JSON
    render that still opens+closes a physical connection would not fix it.
  • Cache the HostProfile object + its gathered-at timestamp, not the serialized JSON; build the
    JSON per call as today so per-role/per-config differences still show up correctly.
  • Keep DarlingStoreHostProfile.GatherAsync itself uncached — --check-settings and
    DarlingCliCommandsHostCheckTests must see a live read every time, only the MCP/web tool layer
    caches.
  • Add gathered_at to the payload as a plain DateTime (Kind=Utc) property in the anonymous
    object (matches DarlingFleetReader's GeneratedAt -> default System.Text.Json ISO-8601 with
    Z, no custom converter in McpHelpers.JsonOptions) — BUT grep
    Darling.Tests/McpPayloadContractCensusTests.cs and McpPayloadClockFrameDisciplineTests.cs for
    _at first: there is likely a pinned contract rule about *_at keys (UTC kind / ISO shape) that
    a new key could trip, and that must be satisfied/updated rather than guessed at.
  • Unit tests (new, with injected clock + injected gather delegate, no real HostProfile/DB needed —
    consider making the cache generic StoreHostProfileCache<T> so tests can use a trivial payload
    type): two concurrent calls gather once (use a gather delegate with a TaskCompletionSource gate
    to force overlap), a call inside 5 minutes gathers nothing (counter stays 1), a call after 5
    minutes (advance the injected clock) gathers again (counter becomes 2), and a throwing gather is
    not cached (next call gathers again, counter increments, no stuck faulted state).
  • Revert-prove by temporarily reverting the cache wiring in GetStoreHost back to a direct
    GatherAsync call and confirming the new cache unit tests fail to compile/fail (they test the
    cache class directly, so this mainly proves the tool still calls through the cache — inspect by
    diff, or add one live/integration-style assertion that a second call within 5 minutes returns the
    identical gathered_at).

Item 3 (Low 3, trimmed PostgresConfig). Two call sites, both currently pass config.Postgres
directly:

  • DarlingMcpHostService.cs:489: builder.Services.AddSingleton(config.Postgres); ->
    builder.Services.AddSingleton(new PostgresConfig { Managed = config.Postgres.Managed, DataDirectory = config.Postgres.DataDirectory });
  • DarlingWebHostService.cs:787: ConfigurePipeline(_app, postgres, networkMode, networkListenIp, allowedCidr, accessToken, oidcClient, publicBaseUrlHost, config.Postgres);
    -> build the same trimmed copy in a local variable right before this call and pass that instead.
    ConfigurePipeline's signature/parameter name (postgresConfig) does not need to change — it
    already just flows through to DarlingWebEndpoints.MapAll -> BuildReadDispatch's closure at
    DarlingWebEndpoints.cs:2950, so trimming at the call site is sufficient and the 4 test callers of
    ConfigurePipeline (DarlingMcpHostGateLiveTests.cs:82, DarlingWebFailureHandlingTests.cs:210,
    DarlingWebHostGateLiveTests.cs:80, DarlingWebResponseCompressionTests.cs:72) are untouched.
  • PostgresConfig (DarlingConfig.cs:462) is a plain sealed class with settable properties, so the
    object-initializer form compiles as-is; no required members to worry about.

Item 4 (Low 4) — two independent halves.

  • Census test: reuse McpToolsListBudgetTests.BuildServedTools()'s existing regex approach (it
    already extracts the registered-tool-class set from WithGeminiCompatibleTools<(\w+)> in
    DarlingMcpHostService.cs). New test: for every [McpServerTool] method on every registered class,
    every parameter type where McpServedSchema.IsServiceParameter(type) is true (minus
    McpToolGuideCatalog, which McpSchemaCompat registers itself, not DarlingMcpHostService.cs),
    assert the raw source text of DarlingMcpHostService.cs contains either AddSingleton<TypeName> or
    AddSingleton(new TypeName for that type's Name (strip a trailing ? — reference-type nullable
    annotations don't appear in the runtime Type, so this shouldn't even be needed, but the today's
    distinct complex types are exactly NpgsqlDataSource, PostgresConfig, DarlingAnalysisService,
    ILogger). Note: BEFORE item 3's fix, AddSingleton(config.Postgres) does NOT name the type in
    source text, so this census test only passes once item 3 lands — item 3 and item 4's census are
    coupled by design; write item 4 to expect the post-item-3 shape, and revert-prove by reverting
    just the item-3 line and confirming this new test fails.
  • UNC refusal: do NOT touch DarlingManagedPostgres.ResolveDataDirectory itself (shared by the
    writer/provisioning path too — changing it is out of scope and riskier). Instead add a small
    profile-local helper in DarlingStoreHostProfile.cs, e.g.
    internal static string? TryResolveProfileDataDirectory(PostgresConfig postgres) that calls
    DarlingManagedPostgres.ResolveDataDirectory and returns null when the resolved path
    StartsWith(@"\\", StringComparison.Ordinal) (mirrors DarlingManagedPostgres.TryResolveConfPath's
    own UNC refusal at DarlingManagedPostgres.cs:1874). Use it at BOTH: (a) DarlingStoreHostProfile.cs:623
    (GatherSettingProfilesAsync's dataDirectory local — this is the one that reaches
    File.ReadAllText/ReadConfAssignments, the actual NTLM-relay vector the review describes), and
    (b) ResolveVolumeAnchor's managed arm (DarlingStoreHostProfile.cs:322-323) — fall back to
    AppContext.BaseDirectory the same way the BYO branch already does, so the DriveInfo touch in
    GatherDataVolume is covered by the same choke point. Unit-test as a pure function: a
    PostgresConfig { Managed = true, DataDirectory = @"\\host\share\pg" } resolves to null (and a
    live-shaped test, if time allows, that a UNC-configured GetStoreHost call reports every setting
    as not-managed / the not-managed source text, never throws, and never attempts a file read).

Items 5/6 (Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs).

  • Item 5: inside the per-role loop, after parsing managed, add: store.size_bytes and
    store.timescale_version are not JSON null; and uncompressed_chunk_count equals a fresh owner
    read via the existing public DarlingStoreHostProfile.UncompressedChunkSizeSql constant, executed
    against the already-open owner connection immediately before the comparison (not once cached at
    the top of the test) — the shared rig runs other live tests and TimescaleDB's own background jobs
    can flip is_compressed mid-run, so read owner's count as close to the role's own read as
    practical.
  • Item 6: replace private const string RolePassword = "HostReadTestPw0123456789abcdef01"; with
    private static readonly string RolePassword = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
    (add using System.Security.Cryptography;) — hex output is safe to interpolate directly into the
    existing DDL string (no quoting characters). Scope is this file only; DarlingSecuritySplitLiveTests
    uses the same pattern but is explicitly out of scope per the brief.
  • Both of these tests' GetStoreHost calls need updating for item 2's new cache parameter too
    (pass a fresh new StoreHostProfileCache(TimeSpan.FromMinutes(5)) per test, or per call — either
    is fine since these are throwaway instances).

Item 7 (description tail + budget). All three edits land strictly after <<GUIDE>> in
DarlingMcpStoreHostTools.cs's [Description(...)] (the marker sits mid-string on the line ending
"...the #4207/#4211 class of drift. <> Reports the..." — head is everything before it):

  1. Fix "data_volume_note says so when managed is false" -> "data_volume.note says so when managed
    is false" (the payload key is data_volume.note, not data_volume_note).
  2. The data_volume (...) field list currently reads "data_volume (total_bytes, free_bytes,
    filesystem; ...)" and omits ready even though the payload has it (ready = profile.DataVolume.IsReady,
    DarlingMcpStoreHostTools.cs:110) — add ready to that parenthetical list.
  3. Add a sentence describing gathered_at and the 5-minute cache (e.g. "gathered_at (UTC) is when
    this snapshot was taken; the store/settings facts are cached for up to 5 minutes and shared
    across callers, so a burst of calls costs one live read.").
    Because all three land in the tail (after <<GUIDE>>), the served head (tools/list) should not
    change length, so Darling.Tests/McpToolsListBudget/DarlingMcpStoreHostTools.txt's
    tool get_store_host 510 line and McpToolsListBudgetTests.TotalCeilingBytes should NOT need
    updating — confirm this by running McpToolsListBudgetTests after the edit rather than
    hand-measuring; if it turns out the head DID move, re-measure both per the brief and append a new
    change-log comment line above TotalCeilingBytes (keep every existing one, per that file's own
    convention of never deleting history entries — see the block of /* #4198 ... */ /
    /* #4214 part 2 ... */ comments immediately above the constant).

sweeps.js (small-fix version exists — do this, not the "leave the poll" fallback).
app.js:129 currently calls renderSweeps(main) with no options even on the 60s poll tick (see
the doc comment at app.js:113-117: "Not meaningful to any other page today, so every other
renderX() call below ignores it" — sweeps.js is exactly that gap). Fix:

  • app.js:129: renderSweeps(main) -> renderSweeps(main, opts).
  • sweeps.js: renderSweeps(main, opts) threads opts down to renderStoreHost(storeHostBox, opts)
    (the other 4 sibling sections can stay as they are — only Store host is the expensive one per the
    review).
  • Hold a module-level let lastStoreHostPayload = null; in sweeps.js. In renderStoreHost(box, opts):
    if opts?.poll && lastStoreHostPayload, render the SAME view function from the cached payload
    synchronously (no readTool fetch) into the freshly-created box (the whole page's DOM is
    rebuilt every call, so the box itself is always new — only the fetch is skippable, not the
    render). Otherwise (first paint, or hashchange/span-control triggered re-navigation), fetch via
    readTool("get_store_host", {}) as today and set lastStoreHostPayload = res.data on success.
    spanControl()'s change handler (sweeps.js ~105-109) calls renderSweeps(mainNode) with no
    opts — that is a real user action (span changed), so it correctly falls through to a fresh
    fetch, which is desired.
  • This is a small, surgical change (thread one parameter + one module-level variable + one
    conditional), so it should be done rather than falling back to "leave the poll" — but if it turns
    out messier once inside the real render function (need to check the exact shape of whatever
    function currently builds the DOM from res.data around sweeps.js:425-470 to confirm it can be
    factored into a pure "build from payload" function reusable for both the fetch and the
    cache-replay paths), the brief explicitly permits leaving the poll with a one-line note instead.

Process note for the coordinator

The brief's Claude-Session trailer
(https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3) differs from this harness's own
system-reminder trailer (https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ). No commits
were made this session so it did not matter here, but the next lane picking this up should use
whichever trailer its own system-reminder supplies (harness-level instructions win over a brief's
embedded value) and can note the brief's URL in its own report if the two still disagree.

What's left (everything)

No code written, no tests added, no build run, no rig started. The plan above is validated by a
second-opinion review of the full file-level orientation (exact line numbers for every touch
point), so the next lane should be able to go straight to editing without re-reading the review
comment or re-deriving the cache design.

erikdarlingdata and others added 5 commits September 25, 2026 10:49
Round-1 review Low 3: the full PostgresConfig (owner connection string
included) was injectable via DI at both the MCP and web host wiring, even
though get_store_host's GatherAsync only ever reads Managed/DataDirectory
off it. Register a trimmed copy at both call sites instead, so a future
[McpServerTool] that takes a PostgresConfig parameter cannot receive the
owner secret through this seat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Round-1 review: three fixes to the description tail (strictly after
<<GUIDE>>, so the served tools/list head is unchanged - confirmed by
McpToolsListBudgetTests, 5/5 green with no fixture edit needed).

- data_volume_note -> data_volume.note (the payload key is nested under
  data_volume, not a flat data_volume_note).
- Add the missing "ready" field to the data_volume parenthetical list.
- Document gathered_at (UTC) and the 5-minute shared cache ahead of item
  2 landing the field/cache itself, per the brief's item ordering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…settings.source

Round-1 review Medium 1: get_store_host's settings[].source printed a full
local filesystem path for a managed block or operator override, fine on
the CLI's own shell but not fine handed to a remote MCP/web caller.

- HostSettingProfile gains SourceFile/SourceLine (default null/0), set
  only by the managed-attribution branch of GatherSettingProfilesAsync.
  SourceDescription itself is untouched, so --check-settings and the
  startup log line still print the full path as before.
- New DarlingStoreHostProfile.FormatSourceForMcp: passes SourceDescription
  through verbatim when there is no SourceFile (not-managed/unreadable -
  already a kind word, never a path), otherwise renders "<kind> (<path>:
  <line>)" with the path redacted to a data-directory-relative path when
  inside the managed data directory, or the bare file name only when
  outside it (never a leading ../ that would still leak the parent tree).
- get_store_host resolves the managed data directory once and reuses it
  for every settings row via the new formatter, in place of the raw
  SourceDescription.

Tests: 3 new pure-function tests on FormatSourceForMcp (inside data
directory, outside it, no SourceFile) plus the existing DarlingStoreHost-
ProfileTests/StartupHostProfileLogTests/McpPayloadContractCensusTests/
McpToolsListBudgetTests classes, 124/124 passing. Revert-proved: gutting
FormatSourceForMcp to return SourceDescription unconditionally failed the
two new sanitization tests; restored and re-verified green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…d data directory

Round-1 review Low 4, two independent halves.

Census: a new McpServiceParameterDiSeatCensusTests pins that every
DI-service-typed [McpServerTool] parameter (NpgsqlDataSource, PostgresConfig,
DarlingAnalysisService, ILogger today) has an AddSingleton<T>/AddSingleton(new T
seat in DarlingMcpHostService.cs's own source text. Without one, the SDK would
serve that parameter as a client argument instead of resolving it from DI - for
PostgresConfig, a remote caller could then set managed:true with a UNC
dataDirectory. Coupled to item 3 by design: before item 3 trimmed the
registration, AddSingleton(config.Postgres) did not name PostgresConfig in
source text, so this test only started passing once item 3 landed (confirmed
by reverting item 3's line, below).

UNC refusal: new DarlingStoreHostProfile.TryResolveProfileDataDirectory wraps
DarlingManagedPostgres.ResolveDataDirectory and refuses (returns null) a
UNC-resolved path, mirroring DarlingManagedPostgres.TryResolveConfPath's own
UNC refusal for an include directive - without it, a UNC-configured managed
store would make the service open a conf file, or stat a volume, on a remote
share as its own account (an NTLM-relay vector). Does not change
ResolveDataDirectory itself, shared by the writer/provisioning path. Wired at
both call sites: GatherSettingProfilesAsync's dataDirectory local (falls
through to the same not-managed path a bring-your-own store already takes -
never opens a file), and ResolveVolumeAnchor's managed arm (falls back to
AppContext.BaseDirectory, same as its existing BYO branch).

Tests: McpServiceParameterDiSeatCensusTests (new) plus 3 new pure-function
tests on TryResolveProfileDataDirectory/ResolveVolumeAnchor. 135/135 passing
(4 skipped, live-gated, no rig this pass). Revert-proved twice: reverting
item 3's AddSingleton line failed the new census test; separately, gutting
TryResolveProfileDataDirectory's UNC check failed both new UNC tests. Both
restored and re-verified green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…oles

Round-1 review Low 6: DarlingMcpStoreHostToolsLiveTests.cs's two LOGIN test
roles (each granted SELECT on every table in collect) used a hardcoded
password that is public in this repository. Cleanup runs in finally, but a
cleanup failure after a failed body is swallowed, and a killed run skips
cleanup outright, so a role that survives on a reachable rig would be a
known, reusable credential. Generate the password fresh per run instead
(Convert.ToHexString(RandomNumberGenerator.GetBytes(16)) - hex output needs
no escaping in the DDL string). Scope is this file only;
DarlingSecuritySplitLiveTests uses the same pattern but is out of scope here.

Build only (this is a live-gated test class, no rig this pass): 0 warnings,
0 errors; the class's own live Fact still skips cleanly without
DARLING_TEST_PG set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Round-1 fix lane, second pass: 5 of 8 items landed, context watchdog stopped the 6th mid-design

Branch feature/4214-store-host-read, now at 7607187d. Five items committed and pushed individually
(build 0/0 warnings-errors, targeted tests green, each committed and pushed as its own checkpoint per the
brief). Executed the previous lane's plan from
#4282 (comment) in the ordered
sequence: 3, 7, 1, 4, 6, then hit the context watchdog's hard block while designing item 2 (the cache),
before writing any of its code. Nothing uncommitted; working tree clean at 7607187d.

Item 3 (Low 3, trimmed PostgresConfig) — 722da337

  • Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs (~line 489): the DI seat is now
    builder.Services.AddSingleton(new PostgresConfig { Managed = config.Postgres.Managed, DataDirectory = config.Postgres.DataDirectory });
    instead of registering the whole config.Postgres (which also carries the owner connection string).
  • Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs (~line 787): same trimmed copy
    built locally and passed to ConfigurePipeline in place of config.Postgres.
  • No dedicated test at this checkpoint (item 4's new census test below covers it); build 0/0.

Item 7 (description tail + budget) — 7dfd1d48

  • Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs's [Description(...)], all
    three edits strictly after <<GUIDE>>: data_volume_note -> data_volume.note; added the missing
    ready field to the data_volume parenthetical; added a sentence documenting gathered_at (UTC) and the
    5-minute shared cache — written ahead of item 2 actually landing the field/cache, per the brief's item
    ordering.
  • McpToolsListBudgetTests: 5/5 green with no fixture edit needed, confirming the served tools/list head
    length did not move.

Item 1 (Medium 1, conf paths) — 7988c7b0

  • HostSettingProfile (DarlingStoreHostProfile.cs) gained trailing string? SourceFile = null, int SourceLine = 0.
    Only the managed-attribution ctor call site (~line 659) sets them, from attribution.File/attribution.Line.
    SourceDescription itself is untouched, so --check-settings and the startup log line are unaffected.
  • New DarlingStoreHostProfile.FormatSourceForMcp(HostSettingProfile, string? dataDirectory) + private
    SanitizeSourcePathForMcp: passes SourceDescription through verbatim when SourceFile is null
    (not-managed/unreadable — already a kind word, never a path); otherwise renders "<kind> (<path>:<line>)"
    with the path reduced to a data-directory-relative path when inside the managed data directory, or the
    bare file name only when outside it (never a leading ..\ that would still leak the parent tree).
  • DarlingMcpStoreHostTools.cs's GetStoreHost: resolves mcpDataDirectory once
    (postgresConfig.Managed ? DarlingManagedPostgres.ResolveDataDirectory(postgresConfig) : null), reused
    for every settings row via the new formatter in place of raw SourceDescription.
  • Tests: 3 new pure-function tests in DarlingStoreHostProfileTests.cs (file inside data directory, file
    outside it, no SourceFile). 124/124 passing at this checkpoint.
  • Revert-proved: temporarily gutted FormatSourceForMcp to return SourceDescription unconditionally —
    both new sanitization tests failed with the expected diff (raw path vs. sanitized); restored, rebuilt
    clean, re-verified green.
  • Open thread for the next lane (caught by an advisor pass during item 2's design, not yet fixed): the
    comment on mcpDataDirectory in DarlingMcpStoreHostTools.cs says it is "the SAME data directory
    GatherSettingProfilesAsync resolved" — after item 4 landed (below), that stopped being strictly true
    under a UNC-configured managed store: GatherSettingProfilesAsync now refuses via
    TryResolveProfileDataDirectory (returns null), but the tool's mcpDataDirectory still calls the raw
    DarlingManagedPostgres.ResolveDataDirectory (would return the UNC path, non-null). Behaviorally harmless
    today — in that scenario every setting's SourceFile is null, so FormatSourceForMcp never reaches the
    sanitizer at all — but the tool should be swapped to call TryResolveProfileDataDirectory too, both so the
    comment is accurate again and so there is no divergent path at all. Small, one-line fix; fold into
    whichever commit touches this file next.

Item 4 (Low 4, two halves) — 880dbd58

  • New Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs: for every [McpServerTool] method on
    every registered tool class, every parameter type where McpServedSchema.IsServiceParameter is true
    (minus McpToolGuideCatalog), asserts DarlingMcpHostService.cs's raw source text contains either
    AddSingleton<Name> or AddSingleton(new Name for that type. Also pins today's exact distinct set:
    DarlingAnalysisService, ILogger, NpgsqlDataSource, PostgresConfig.
    • For the next lane: once item 2 adds a StoreHostProfileCache parameter to GetStoreHost, this
      pinned list needs StoreHostProfileCache added (a deliberate, reasoned addition — say so in the
      commit), and the DI registration must be spelled AddSingleton<StoreHostProfileCache>(StoreHostProfileCache.Shared)
      (the typed-generic overload) — a bare AddSingleton(StoreHostProfileCache.Shared) matches neither
      Contains check in this test and will fail it. An advisor pass caught this before any code was written.
  • New DarlingStoreHostProfile.TryResolveProfileDataDirectory(PostgresConfig): wraps
    DarlingManagedPostgres.ResolveDataDirectory and refuses (returns null) a UNC-resolved path, mirroring
    DarlingManagedPostgres.TryResolveConfPath's own UNC refusal for an include directive. Does NOT change
    ResolveDataDirectory itself (shared by the writer/provisioning path). Wired at both
    GatherSettingProfilesAsync's dataDirectory local (falls through to the same not-managed path a
    bring-your-own store already takes — never opens a file) and ResolveVolumeAnchor's managed arm (falls
    back to AppContext.BaseDirectory, same as its existing BYO branch).
  • Tests: 3 new pure-function tests (UNC config -> null; local config -> resolved path;
    ResolveVolumeAnchor with a UNC config falls back to AppContext.BaseDirectory). 135/135 passing (4
    skipped, live-gated, no rig this pass) at this checkpoint.
  • Revert-proved twice: (a) reverted item 3's AddSingleton line back to AddSingleton(config.Postgres) —
    the new census test failed exactly as designed (it is deliberately coupled to item 3); (b) gutted
    TryResolveProfileDataDirectory's UNC check to a no-op passthrough — both new UNC tests failed. Both
    restored and re-verified green afterward.

Item 6 (Low 6, hardcoded test password) — 7607187d

  • Darling/Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs: RolePassword is now
    Convert.ToHexString(RandomNumberGenerator.GetBytes(16)) (generated fresh per run) instead of a hardcoded
    literal that was public in this repository. Scope kept to this file only, per the brief
    (DarlingSecuritySplitLiveTests uses the same pattern but is explicitly out of scope).
  • Build only (this is a live-gated test class, no rig this pass): 0 warnings, 0 errors; the class's own
    live Fact still skips cleanly without DARLING_TEST_PG set.

Item 2 (Medium 2, the cache) — NOT STARTED, design fully settled and advisor-validated

No code written. What follows is the exact design an advisor pass validated against this session's full
transcript, so the next lane should be able to execute directly.

One correction to the ORIGINAL plan comment, backed by primary-source evidence, already applied to item
7's committed description text (no re-touch needed there):
the plan said to emit gathered_at as a plain
DateTime with Kind=Utc because that's supposedly what DarlingFleetReader.GeneratedAt does, serializing
as ISO-8601 with a trailing Z. That is wrong: DarlingFleetReader.cs:1153, DarlingAgReader.cs:185,
DarlingMcpConfigHistoryTools.cs:327 (NaiveUtc helper) and DarlingMcpTrendTools.cs:737 all
DateTime.SpecifyKind(..., DateTimeKind.Unspecified) before serializing — the established convention for a
_at timestamp on an MCP payload in this codebase is naive-UTC (no trailing Z), confirmed by
McpPayloadClockFrameDisciplineTests's own doc comment ("naive-UTC fields every other timestamp on that
payload uses"). Emit gathered_at = DateTime.SpecifyKind(gatheredAtUtc, DateTimeKind.Unspecified).

The cache class (new file, e.g. Darling/PerformanceMonitor.Darling.Service/StoreHostProfileCache.cs,
namespace PerformanceMonitor.Darling.Service):

internal sealed class StoreHostProfileCache
{
    internal static readonly StoreHostProfileCache Shared = new(TimeSpan.FromMinutes(5));

    private sealed record CacheEntry(HostProfile Profile, DateTime GatheredAtUtc);

    private readonly TimeSpan _ttl;
    private readonly Func<DateTime> _utcNow;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private volatile CacheEntry? _entry;

    internal StoreHostProfileCache(TimeSpan ttl, Func<DateTime>? utcNow = null)
    {
        _ttl = ttl;
        _utcNow = utcNow ?? (() => DateTime.UtcNow);
    }

    internal async Task<(HostProfile Profile, DateTime GatheredAtUtc)> GetOrGatherAsync(
        Func<CancellationToken, Task<HostProfile>> gather, CancellationToken cancellationToken)
    {
        if (TryGetFresh() is { } fresh)
        {
            return (fresh.Profile, fresh.GatheredAtUtc);
        }

        await _gate.WaitAsync(cancellationToken);
        try
        {
            if (TryGetFresh() is { } freshAfterWait)
            {
                return (freshAfterWait.Profile, freshAfterWait.GatheredAtUtc);
            }

            var profile = await gather(cancellationToken);
            var entry = new CacheEntry(profile, _utcNow());
            _entry = entry;
            return (entry.Profile, entry.GatheredAtUtc);
        }
        finally
        {
            _gate.Release();
        }
    }

    private CacheEntry? TryGetFresh()
    {
        var entry = _entry;
        return entry is not null && _utcNow() - entry.GatheredAtUtc < _ttl ? entry : null;
    }
}

Key point an advisor pass added on top of the original plan: hold one immutable CacheEntry behind a
single volatile reference field, not a bare (HostProfile, DateTime) tuple field read without a lock —
a tuple field is not read/written atomically and can tear under concurrent access; a single reference
assignment is atomic and volatile gives the needed cross-thread visibility for the lock-free fast path.

Wiring (all in item 2's own commit, to keep the build green — do not defer any of this to item 5):

  • DarlingMcpStoreHostTools.cs's GetStoreHost gains a third parameter, StoreHostProfileCache cache
    (non-nullable — every caller, production and test, always supplies one). Keep the existing
    postgresConfig is null early return ahead of any cache interaction. Move
    postgres.OpenConnectionAsync(...) from its current unconditional call inside the try block to INSIDE
    the gather delegate passed to cache.GetOrGatherAsync(...), so a cache hit opens no connection. Add
    gathered_at = DateTime.SpecifyKind(gatheredAtUtc, DateTimeKind.Unspecified) to the returned JSON object
    (the gatheredAtUtc the cache call returns alongside Profile). While here, fix the mcpDataDirectory
    local to call DarlingStoreHostProfile.TryResolveProfileDataDirectory instead of the raw
    DarlingManagedPostgres.ResolveDataDirectory (see item 1's open thread above).
  • DarlingMcpHostService.cs (near the item-3 registration, ~line 489-493): add
    builder.Services.AddSingleton<StoreHostProfileCache>(StoreHostProfileCache.Shared); — must use the typed
    generic overload exactly as spelled, or McpServiceParameterDiSeatCensusTests (item 4, above) fails.
  • DarlingWebEndpoints.cs's BuildReadDispatch, the ["get_store_host"] closure (~line 2950): add
    StoreHostProfileCache.Shared as the third argument — a direct static reference, not a new
    BuildReadDispatch parameter (unlike postgresConfig, the cache instance never varies by caller, so no
    threading is needed).
  • Not yet located/confirmed by this session (the watchdog block hit before this search completed): every
    other call site of DarlingMcpStoreHostTools.GetStoreHost(...) needs the same third argument, a fresh
    new StoreHostProfileCache(TimeSpan.FromMinutes(5)) per test/fixture. Known from the original plan
    comment: Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs and (by name only, not yet confirmed to
    exist under that exact name) a DarlingMcpStoreHostBudgetLiveTests.cs. Grep
    DarlingMcpStoreHostTools.GetStoreHost( across the whole repo before starting
    to get the complete,
    confirmed list rather than trusting this note.
  • Update McpServiceParameterDiSeatCensusTests's pinned list (this session's own new test, item 4) to add
    StoreHostProfileCache to the expected four-name array, with a one-line reason in that commit.

Tests to add (new file, e.g. Darling.Tests/StoreHostProfileCacheTests.cs, using an injected clock and
a trivial gather delegate — no real HostProfile/DB needed, though a minimal fixture HostProfile is cheap
to build if the delegate needs to return one; see StartupHostProfileLogTests.BuildFixtureProfile() for the
shape):

  • Two concurrent calls gather once: start call 1 with a gather that blocks on a TaskCompletionSource
    after incrementing a counter and signaling a second TCS; await that signal (guarantees call 1 now holds
    the gate); start call 2 (its synchronous prefix runs to its own await on the gate — cache is still cold,
    deterministically, no Task.Delay needed); release call 1's TCS; Task.WhenAll both; assert the counter
    is 1.
  • A call inside 5 minutes gathers nothing (counter stays 1, GatheredAtUtc identical across both calls) —
    use the injected clock, not real time.
  • A call after 5 minutes (advance the injected clock) gathers again (counter becomes 2).
  • A throwing gather is not cached: Assert.ThrowsAsync on the first call, then a second call gathers
    again (counter increments, no stuck faulted state) — the finally releases the semaphore even when
    gather throws, and nothing is ever assigned to _entry on that path.
  • Revert-prove per the brief: temporarily break the re-check-after-wait or the TTL comparison, confirm the
    relevant new test(s) fail, restore, re-verify green.

Not started at all

  • sweeps.js (small-fix version, per the plan comment): thread opts from app.js:129's poll-tick
    renderSweeps(main) call through to renderStoreHost, with a module-level lastStoreHostPayload so a
    60-second poll tick replays the cached payload instead of re-fetching. Not touched this session.
  • Item 5 (DarlingMcpStoreHostToolsLiveTests.cs new assertions: store.size_bytes/timescale_version
    not null, uncompressed_chunk_count matches a fresh owner-side read). Not touched this session — and its
    GetStoreHost call site will need the item-2 cache argument added at the same time regardless of who
    lands it first.

Verification run at the last checkpoint (commit 7607187d)

Darling/Darling.Tests/Darling.Tests.csproj -c Debug: 0 Warning(s), 0 Error(s). Targeted run
(DarlingStoreHostProfileTests, McpServiceParameterDiSeatCensusTests, McpToolsListBudgetTests,
McpPayloadContractCensusTests, StartupHostProfileLogTests, DarlingCliCommandsHostCheckTests,
DarlingMcpStoreHostToolsLiveTests): 135 total, 0 failed, 4 skipped (live-gated, no rig this pass). No full
suite run, per the brief. Installer.Tests never run. No process started or killed this session (no rig, no
background service).

Nothing left uncommitted. Branch pushed through 7607187d, PR #4282 confirmed OPEN/draft at that head
before writing this comment.

erikdarlingdata and others added 5 commits September 25, 2026 11:51
…UNC refusal

GetStoreHost's mcpDataDirectory local called the raw DarlingManagedPostgres.ResolveDataDirectory,
while GatherSettingProfilesAsync (Low 4) now refuses a UNC-resolved data directory via
TryResolveProfileDataDirectory. The doc comment on mcpDataDirectory claimed it was "the SAME data
directory GatherSettingProfilesAsync resolved" - no longer true under a UNC-configured managed
store. Harmless today (every setting's SourceFile is null in that case, so FormatSourceForMcp never
reaches the sanitizer), but switches mcpDataDirectory to the same TryResolveProfileDataDirectory
call so the comment is accurate again and there is no divergent path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
New StoreHostProfileCache (Darling/PerformanceMonitor.Darling.Service/StoreHostProfileCache.cs):
a single immutable CacheEntry behind one volatile reference field (not a bare tuple field, which
can tear under concurrent reads), a SemaphoreSlim gate for single-flight gather, and an injected
clock. A cache hit never calls the gather delegate at all, so it never opens a connection. A failed
gather is never cached: the finally always releases the gate, and _entry is only assigned after
gather returns successfully.

Wiring: GetStoreHost takes a third StoreHostProfileCache parameter; postgres.OpenConnectionAsync
moved inside the gather delegate; the response gains gathered_at (naive-UTC, matching every other
"_at" timestamp on an MCP payload in this codebase per DarlingFleetReader/DarlingAgReader/
DarlingMcpConfigHistoryTools/DarlingMcpTrendTools - not the DateTimeKind.Utc the original plan
named). DarlingMcpHostService.cs registers the process-wide StoreHostProfileCache.Shared singleton
via the typed-generic AddSingleton<T> overload McpServiceParameterDiSeatCensusTests requires.
DarlingWebEndpoints.cs's direct-call dispatch passes the same Shared instance.

StoreHostProfileCache is public, not internal as the handoff's draft had it: GetStoreHost is a
public [McpServerTool] method, and a parameter type may never be less accessible than the method
(CS0051) - matches the existing PostgresConfig/DarlingAnalysisService precedent. GetOrGatherAsync
itself stays internal, since its signature carries the internal HostProfile type.

Test call sites (DarlingMcpStoreHostBudgetLiveTests.cs, DarlingMcpStoreHostToolsLiveTests.cs) each
get a fresh StoreHostProfileCache instance, never the production Shared singleton. The two calls in
DarlingMcpStoreHostToolsLiveTests.cs's role loop (managed config then BYO config against the same
data source) each need their OWN fresh cache: the cache holds one un-keyed entry, so sharing one
across those two calls would serve the first call's cached profile back for the second regardless
of which PostgresConfig was passed.

New Darling/Darling.Tests/StoreHostProfileCacheTests.cs, 4 tests: two concurrent callers gather
once (proven deterministically via a two-TCS choreography, no Task.Delay), a call inside 5 minutes
gathers nothing, a call after 5 minutes gathers again, a throwing gather is not cached. Revert-proved:
gutted TryGetFresh to always return null, confirmed both TwoConcurrentCalls_GatherOnce and
ACallInsideFiveMinutes_GathersNothing fail, restored, rebuilt 0/0, reverified all green.

McpServiceParameterDiSeatCensusTests' pinned service-parameter-type list grows from four names to
five (StoreHostProfileCache added), with the reason in the test's own comment.

Build: 0 Warning(s), 0 Error(s). Targeted run (StoreHostProfileCacheTests,
McpServiceParameterDiSeatCensusTests, McpPayloadContractCensusTests, McpToolsListBudgetTests,
DarlingStoreHostProfileTests, StartupHostProfileLogTests, DarlingCliCommandsHostCheckTests,
DarlingMcpStoreHostToolsLiveTests, DarlingMcpStoreHostBudgetLiveTests, FleetSweepWebFeedTests,
DarlingWebAssetsTests, SerialLoopStoreSizeSourceTests): 220 total, 0 failed, 6 skipped (live-gated,
no rig this pass).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…IDisposable (CA1001)

A non-incremental rebuild surfaced CA1001: the class owns a disposable SemaphoreSlim field
(_gate) but was not itself IDisposable. Mirrors DarlingWebOidcClient's own gate disposal.
Safe for the Shared singleton despite two hosts (DarlingMcpHostService, DarlingWebEndpoints.cs's
direct-call dispatch) both holding a reference to it: both register it with the DI INSTANCE
overload (AddSingleton<StoreHostProfileCache>(Shared)), and the built-in container never disposes
an instance it did not itself construct, so no container shutdown disposes Shared out from under
the other host.

Build: 0 Warning(s), 0 Error(s) on a --no-incremental rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…ng on a poll tick

app.js's route() now forwards opts (the 60s poll's { poll: true } marker) to renderSweeps, which
forwards it to renderStoreHost. renderStoreHost is split into itself (fetch/cache) and a new
renderStoreHostPayload(box, p) (pure render), with a module-level lastStoreHostPayload. A poll
tick with an already-fetched payload replays it through renderStoreHostPayload instead of calling
readTool("get_store_host", {}) again; every non-poll call (first paint, hashchange, the span
control) always fetches fresh. The profile this card reports (host RAM/CPUs/disk, PostgreSQL/
TimescaleDB versions, sizing verdicts) changes on a hardware or version change, never per-tick, so
a 60-second poll paying a fresh network round trip for it was pure waste - the tool's own 5-minute
server-side cache (item 2) already made repeat calls cheap for the STORE, but not for the browser's
own network cost.

FleetSweepWebFeedTests.TheSweepPage_IsWiredIntoTheShellAndTheRouter pinned the literal
"renderSweeps(main)" call text; updated to "renderSweeps(main, opts)" to match.

Build: 0 Warning(s), 0 Error(s) (--no-incremental). Targeted run (StoreHostProfileCacheTests,
McpServiceParameterDiSeatCensusTests, McpPayloadContractCensusTests, McpToolsListBudgetTests,
FleetSweepWebFeedTests, DarlingWebAssetsTests, SerialLoopStoreSizeSourceTests,
DarlingCliCommandsHostCheckTests): 164 total, 0 failed, 4 skipped (live-gated, no rig this pass).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…e live test

GetStoreHost_AsMcpAndViewerRoles_SurfacesAllFourVerdicts_Live gains three assertions per role,
against the managed-config response: store.size_bytes and store.timescale_version are not null
(both come off reads PUBLIC can run with no Darling-authored GRANT, so a regression there is a
silent "unavailable" null rather than a thrown exception), and store.uncompressed_chunk_count
matches a fresh owner-side read of the exact same query (DarlingStoreHostProfile.
UncompressedChunkSizeSql, reused rather than duplicated) taken immediately after the tool call.

Written per the handoff; not run live in this pass (no rig). Build: 0 Warning(s), 0 Error(s).
The class still skips cleanly without DARLING_TEST_PG.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Round-1 fix lane, third pass: all 4 remaining items landed

Branch feature/4214-store-host-read, now at 682b465a. Executed the previous lane's handoff
(#4282 (comment)) in the brief's
order. Every item built 0 Warning(s)/0 Error(s), was committed, and pushed as its own checkpoint. Nothing
uncommitted; working tree clean at 682b465a.

Item 1's open thread — b40531dd

DarlingMcpStoreHostTools.cs's GetStoreHost: mcpDataDirectory now calls
DarlingStoreHostProfile.TryResolveProfileDataDirectory instead of the raw
DarlingManagedPostgres.ResolveDataDirectory, so it agrees with GatherSettingProfilesAsync's own UNC
refusal (Low 4) — the one-line fix the handoff named. Tests: DarlingStoreHostProfileTests,
DarlingCliCommandsHostCheckTests (57 total, 0 failed).

Item 2, the cache — f4b2e034, plus a follow-up 5e4eecdc

New StoreHostProfileCache.cs: one immutable CacheEntry behind a volatile reference field, a
SemaphoreSlim gate for single-flight gather, an injected clock. A cache hit never calls the gather
delegate, so it opens no connection; a failed gather is never cached (_entry only assigned after a
successful gather).

Deviation from the handoff's draft, forced by the compiler, not a design choice: the handoff specified
internal sealed class StoreHostProfileCache. That does not compile — GetStoreHost is public
([McpServerTool]), so CS0051 rejects a less-accessible parameter type, exactly like PostgresConfig and
DarlingAnalysisService are already public for the same reason. Made the class public; kept
GetOrGatherAsync itself internal since its signature carries the internal HostProfile type. A
non-incremental rebuild later surfaced CA1001 (owns disposable _gate, not itself IDisposable) — fixed by
implementing IDisposable, mirroring DarlingWebOidcClient's own gate disposal; safe for the Shared
singleton because both hosts register it via the DI instance overload, which the container never
auto-disposes.

Wiring: GetStoreHost takes a third StoreHostProfileCache cache parameter; OpenConnectionAsync moved
inside the gather delegate; response gains gathered_at (naive-UTC, per the handoff's correction).
DarlingMcpHostService.cs registers StoreHostProfileCache.Shared via the typed-generic overload.
DarlingWebEndpoints.cs passes the same Shared instance directly. Both live test call sites get a fresh
StoreHostProfileCache instance — and in DarlingMcpStoreHostToolsLiveTests.cs's role loop specifically,
each of the two calls (managed config, then BYO config) needed its own fresh cache: the cache holds one
un-keyed entry, so sharing one across those two calls would have served the first call's cached profile back
for the second regardless of which config was passed.

New StoreHostProfileCacheTests.cs, 4 tests: two concurrent callers gather once (deterministic two-TCS
choreography, no Task.Delay), inside-5-minutes gathers nothing, after-5-minutes gathers again, a throwing
gather is not cached. Revert-proved: gutted TryGetFresh to return null, confirmed
TwoConcurrentCalls_GatherOnce and ACallInsideFiveMinutes_GathersNothing both fail, restored, rebuilt 0/0,
reverified green. McpServiceParameterDiSeatCensusTests' pinned list grows from four names to five.

Checkpoint run: 220 total, 0 failed, 6 skipped (live-gated, no rig).

Item 3, sweeps.js — e16d0781

app.js's route() forwards opts to renderSweeps, which forwards it to renderStoreHost.
renderStoreHost split into fetch/cache plus a new pure renderStoreHostPayload(box, p), with a
module-level lastStoreHostPayload. A poll tick (opts.poll) with an already-fetched payload replays it
instead of calling readTool again; every non-poll call (first paint, hashchange, span control) always
fetches fresh. Updated FleetSweepWebFeedTests.TheSweepPage_IsWiredIntoTheShellAndTheRouter, which pinned
the literal "renderSweeps(main)" call text — now "renderSweeps(main, opts)".

Checkpoint run: 164 total, 0 failed, 4 skipped.

Item 5 (the brief's 4th item) — 682b465a

DarlingMcpStoreHostToolsLiveTests.cs gains three assertions per role against the managed-config response:
store.size_bytes and store.timescale_version not null, and store.uncompressed_chunk_count matches a
fresh owner-side read of the exact same query (DarlingStoreHostProfile.UncompressedChunkSizeSql, reused
rather than duplicated) taken immediately after the tool call. Written per the handoff; not run live this
pass
(no rig) — the class still builds and skips cleanly without DARLING_TEST_PG.

Totals and what's left

Non-live checkpoint classes all green across every item (highest single run: 220 total, 0 failed). No full
suite run this pass, per the brief — a separate rig lane runs live classes and the full suite. Installer.Tests
never run. No process started or killed (no rig, no background service). McpToolsListBudgetTests stayed
green throughout with no fixture edit needed (the served schema strips service parameters, so the new cache
parameter never reached the advertised tools/list shape).

All 8 of the original round's items are now landed. Nothing deferred to a new issue — every item in this
lane's brief was small and mechanical enough to finish in-lane, including the two build-forced fixes (the
public class, the IDisposable implementation) that the handoff's draft design didn't anticipate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

# Conflicts:
#	Darling/Darling.Tests/McpToolsListBudgetTests.cs
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Rig pass: live classes and the full suite, once

Branch feature/4214-store-host-read, started at 682b465a (confirmed by git rev-parse HEAD before
touching anything). Ran the wave's remaining live classes and one full-suite pass. No code redesign; no
fixes were needed because nothing failed.

Worktree note

origin/feature/4214-store-host-read was already checked out in another local worktree left over from the
last code lane (not locked, sitting at 682b465a — the same commit this brief names as head, so it was
idle, not active). Git refuses to check the same branch out in two worktrees at once, so instead of
git checkout -B feature/4214-store-host-read origin/feature/4214-store-host-read I used a differently
named local branch (lane-rigpass-4214) tracking the same remote ref, confirmed its HEAD was 682b465a,
and pushed with git push origin HEAD:feature/4214-store-host-read rather than the brief's plain
branch-name push (no local branch by that literal name existed here to push). Effect on origin is identical:
a plain, non-force push to the same branch.

1. Merge origin/dev

git merge origin/dev (dev at 06354a5b) produced one real conflict, exactly where the brief expected it:
Darling/Darling.Tests/McpToolsListBudgetTests.cs. Kept both sides' change-log comment blocks (this PR's
#4214 part 2 lines and dev's #4231/#4279 lines), added one new comment describing the merge, and set
TotalCeilingBytes to the value McpToolsListBudgetTests itself measured on the merged tree:

tools/list total: 175,170 bytes (ceiling 175,170).

175,170 = the shared #4273 base both sides branched from, plus this PR's own +616 (get_store_host) plus
dev's +181 (#4279's Lite-parity head change) — confirmed by the test run, not hand-derived only. All 5 tests
in the class passed on that value.

No other file had real conflict markers (git status showed only that one UU; everything else
auto-merged).

Build: Darling/Darling.Tests/Darling.Tests.csproj -c Debug -> Build succeeded, 0 Warning(s), 0 Error(s).

Merge commit 9e3563d1 pushed to origin/feature/4214-store-host-read. One note: this commit went out via
git commit --no-edit (a plain merge commit) and so carries no Co-Authored-By/Claude-Session trailer. I did
not amend and force-push it to add one, since amending an already-pushed commit is the kind of rewrite the
guardrails say to avoid without a specific instruction to do so. No other commits were needed this pass
(nothing failed), so this is the only commit here.

2. Rig

Built fresh at C:\GitHub\worktrees\rig-h2fix from Darling/artifacts/pg-runtime.zip (dated Sep 23, still
current against fetch-pg-runtime.ps1's PG 18.6 / TimescaleDB 2.30.1 pins, so not refetched). initdb -D C:\GitHub\worktrees\rig-h2fix\data -U darling -A trust --encoding=UTF8, then before the first start,
appended to postgresql.conf:

shared_preload_libraries = 'timescaledb'
port = 55994
listen_addresses = '127.0.0.1'
timescaledb.max_background_workers = 74
max_worker_processes = 85
timezone = 'UTC'
log_timezone = 'UTC'

The worker numbers mirror CI's own darling-pg primary-cluster job in .github/workflows/build.yml
(HypertableCount + 2 = 74, 3 + (HypertableCount + 2) + 8 = 85), not the smaller 8/16 the log-format-only
clusters use, since this rig runs the full live suite. Server log confirms UTC took effect from the first
line (2026-09-25 16:32:45 UTC ... database system is ready to accept connections). Created darlingtest
(suite) and probe (scratch) databases. Before the final full run, dropped and recreated darlingtest fresh
(dropdb --force + createdb) so the full pass didn't inherit rows the class runs had already written.

3. Live/gated classes (DARLING_TEST_PG=Host=127.0.0.1;Port=55994;Username=darling;Database=darlingtest,

DARLING_TEST_PGRUNTIME=C:\GitHub\worktrees\rig-h2fix)

Class Total Failed Skipped Live-ran
DarlingMcpStoreHostToolsLiveTests 1 0 0 1 ran live
DarlingMcpStoreHostBudgetLiveTests 1 0 0 1 ran live
DarlingCliCommandsHostCheckTests 7 0 0 7 ran live
StoreHostProfileCacheTests 4 0 0 n/a (deterministic unit tests, not rig-gated)
McpToolsListBudgetTests 5 0 0 n/a (not rig-gated; run above during merge resolution)
McpServiceParameterDiSeatCensusTests 1 0 0 n/a (not rig-gated)
FleetSweepWebFeedTests 39 0 0 n/a (not rig-gated)
DarlingWebAssetsTests 21 0 0 n/a (not rig-gated)

Every rig-gated class ran its live test(s) with zero skips (confirms DARLING_TEST_PG /
DARLING_TEST_PGRUNTIME reached them) and every class passed clean.

4. StoreHostProfileCache.Shared watch

Checked both live call sites against the process-wide Shared cache risk the brief named:

  • Darling/Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs:119 (managed-config role call) and :150
    (BYO-config role call) each construct their own new StoreHostProfileCache(TimeSpan.FromMinutes(5)) —
    two separate instances, one per call, not .Shared.
  • Darling/Darling.Tests/DarlingMcpStoreHostBudgetLiveTests.cs:56 likewise builds its own fresh instance.

No cross-test or cross-role profile bleed observed; this matches what the prior lane's report already
described fixing. Nothing to change here — reporting the check, not a finding.

5. Fixes

None. Every class above passed on the first run; no code changes were needed in this PR's files.

6. Full suite, once

Darling.Tests.exe with no class filter, same two rig env vars, against the freshly recreated
darlingtest:

Total: 14076, Errors: 0, Failed: 0, Skipped: 29, Not Run: 1, Time: 948.584s (~15.8 min)
  • 0 failures, 0 errors.
  • 29 skipped: all env-gated live tests this rig doesn't set up (CSVLOG/JSONLOG/AUTOEXPLAIN clusters,
    DARLING_TEST_PGRUNTIME_OLD for the PG17-upgrade fixture) — expected, this rig only stands up the one
    primary cluster the brief named.
  • 1 not run: Darling/Darling.Tests/McpSchemaCompatServiceLeakRaceTests.cs:160,
    [Fact(Explicit = true)], correctly excluded by the runner's default (-explicit off). Pre-existing,
    unrelated to this PR's files.
  • Neither of CI flake: PgTarget anomaly/blocking worst-tile assertions fail on first attempts unrelated to the change #4274's known first-attempt flakes nor TrendPayloadBudgetLiveTests' local stream error
    appeared in this run, so there was nothing to isolate and re-run alone.

Installer.Tests was never run.

7. Rig teardown

pg_ctl.exe -D C:\GitHub\worktrees\rig-h2fix\data stop -> server stopped. Data directory left in place;
extracted runtime at C:\GitHub\worktrees\rig-h2fix left for reuse. No process killed by image name; only
the pg_ctl/postgres process this run started.

What the coordinator should double-check

  • The merge commit (9e3563d1) lacks the Co-Authored-By/Claude-Session trailer (see note above under
    "Merge origin/dev"); it's the only commit this pass made.
  • The brief's placeholder session URL (session_01TszxYhJJbTEh4LrZ56NYo3) is identical to the previous
    lane's own session URL from PR comment 5835678714 — looks like a stale copy in the dispatch template. Not
    consumed here since no fix commits were needed, but worth checking for other lanes on this same template.
  • TotalCeilingBytes = 175_170 is a growth-only ceiling; if a future PR on this branch changes any tool
    head byte count, re-measure rather than trust the arithmetic above.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ

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