Repository navigation
get_store_metrics: the job_history block said 'recording' and never said whose eyes the rows are visible to — it now evaluates the view's ownership filter for its own reader and proves rows exist where it can (#3574) - #3585
Conversation
… are visible to, and proves rows exist where it can (#3574) The block reported recording: true from the GUC (#3175) and stopped there. The reader's real question is "will I see rows", and timescaledb_information.job_history is a security_barrier view that shows a row only to a member of the job's owner role or of the database owner (2.28.1 views.sql: pg_has_role(current_user, <datdba>, 'MEMBER') OR pg_has_role(current_user, owner, 'MEMBER')), while the jobs and job_stats views a reader checks first are not filtered. A least-privilege role can list all 110 jobs and read an empty history on a store recording perfectly; that cost a real postmortem. The reader gains a second, failure-isolated statement (JobHistoryEvidenceSql) that evaluates the view's own two pg_has_role tests for the connection doing the reading, counts the rows it sees over a fixed 24-hour window, takes the population half from the unfiltered job_stats, and publishes reader_role, visibility (All/Partial/None), job counts, rows_observed, newest_row_at, jobs_run_in_window, newest_run_started_at and a contradiction flag. The note names the rule with the predicate, the role it read as, and what that role may see; recording on + reader admitted + jobs ran + zero rows is the new CONTRADICTION arm, with its one benign cause and how to settle it. Evaluating the predicate is load-bearing rather than decorative: in managed mode the MCP host connects as the mcp role, which the view filters OUT, so a bare count would have manufactured the very false contradiction the issue is about on every managed store. The block now says "None — the filter, not the table" there and names the role that can see; a bring-your-own owner connection is the self-proving case. Bind is an explicit timestamptz with Kind=Utc — the inverse of the store's naive discipline, because these are TimescaleDB's own TIMESTAMPTZ catalog columns.
|
Reviewed the diff (DarlingStoreMetricsReader.cs, DarlingMcpStoreMetricsTools.cs, DarlingMcpInstructions.cs, TimescaleSupport.cs doc comment, Darling/README.md, plus the test suite). Correctness: Traced the Style/convention note (non-blocking): Parity: This is Darling-only by architecture (central Postgres/TimescaleDB store), and the existing Security: All reads are parameterized/static SQL, no string interpolation of untrusted input, no new secrets or file/process/network surface. No substantive issues found — nicely defensive design (separate failure isolation per read, explicit null-vs-zero contract, live-store round-trip tests). |
There was a problem hiding this comment.
LGTM — reviewed the job_history ownership-filter evidence feature (query logic, visibility/contradiction derivation, failure isolation, Lite/Darling parity, and security surface); no substantive issues, one non-blocking style note posted inline about the deliberate timestamptz-binding exception to the naive-UTC convention.
…spliced once Fifty-nine PRs merged to dev today across the coordinator's lanes and the wave-2 worker's; each lane returned its entry to a buffer instead of touching this file, so that fifty-plus PRs did not each rebase the same twenty lines. This is the one splice. Every entry is one line (the archiver's compact() and the pins read them that way); riders fold into their parent's entry (#3599 under #3590, #3619 under #3611, #3623 under #3616, #3640 under #3633; #3617 test-only and #3661 re-cut as #3666 carry none); #3657's entry is in because it MERGED to dev - the twin to main is what is still pending. [Unreleased] gains a `### Added` above `### Fixed` (Keep-a-Changelog order) for the six new capabilities: per-user theme colours (#3606 / #3577 arm B), routed alert families (#3668 / #3598), the PostgreSQL logging audit tool (#3643 / #3607), the service-side wait sampler (#3645 / #3604), and the log-event classifier with its temp-file / autovacuum parser families (#3646 / #3601, #3664 / #3602 #3603). The other forty-eight are honesty fixes to existing surfaces and append to `### Fixed` after the wave-1 bullets, in PR-number order. Thirty-six reference definitions added for the issues the new entries cite and the index did not yet define; the [Unreleased] group is one ascending run again, which moves [#3557] into its slot (the one deleted line). Nothing under ## [3.8.0] or older is touched; the archive script was not run. One editorial touch: the #3585 entry ended in a dangling "Darling" and now reads "Darling only." (the tool exists only in the Darling MCP host). tools/changelog/changelog_archive.py verify: all PASS (1420 bold entries, floor 1,329; 1,358 distinct refs resolve; CRLF throughout; 356,539 bytes under the 750 KiB ceiling). ChangelogIndexAndArchiveTests: 5/5 pass via a net10.0 harness.
…spliced once (#3672) Fifty-nine PRs merged to dev today across the coordinator's lanes and the wave-2 worker's; each lane returned its entry to a buffer instead of touching this file, so that fifty-plus PRs did not each rebase the same twenty lines. This is the one splice. Every entry is one line (the archiver's compact() and the pins read them that way); riders fold into their parent's entry (#3599 under #3590, #3619 under #3611, #3623 under #3616, #3640 under #3633; #3617 test-only and #3661 re-cut as #3666 carry none); #3657's entry is in because it MERGED to dev - the twin to main is what is still pending. [Unreleased] gains a `### Added` above `### Fixed` (Keep-a-Changelog order) for the six new capabilities: per-user theme colours (#3606 / #3577 arm B), routed alert families (#3668 / #3598), the PostgreSQL logging audit tool (#3643 / #3607), the service-side wait sampler (#3645 / #3604), and the log-event classifier with its temp-file / autovacuum parser families (#3646 / #3601, #3664 / #3602 #3603). The other forty-eight are honesty fixes to existing surfaces and append to `### Fixed` after the wave-1 bullets, in PR-number order. Thirty-six reference definitions added for the issues the new entries cite and the index did not yet define; the [Unreleased] group is one ascending run again, which moves [#3557] into its slot (the one deleted line). Nothing under ## [3.8.0] or older is touched; the archive script was not run. One editorial touch: the #3585 entry ended in a dangling "Darling" and now reads "Darling only." (the tool exists only in the Darling MCP host). tools/changelog/changelog_archive.py verify: all PASS (1420 bold entries, floor 1,329; 1,358 distinct refs resolve; CRLF throughout; 356,539 bytes under the 750 KiB ceiling). ChangelogIndexAndArchiveTests: 5/5 pass via a net10.0 harness.
…le, Opus full tier (#3710) (#3718) * REVIEW-TRAPS.md: the repository's recurring defect shapes, one line each with the PR that bit (#3710) Seven layers (SQL, collectors and deltas, analysis, alerting and notifications, storage and MCP payloads, tests and censuses, workflows and CI, repository mechanics), fifty-odd lines, every one citing the pull request or issue that verified it: the >= limit truncation inference (#3594/#3679/#3699), ORDER BY 3 + 4 folding to a constant (#3613), MAX(max_dop) across plans (#3648), integer division in cntr_value_per_second (#3653 Q9), per-collection alert firing against a shorter cooldown (#3628/#3636), restart zeros in deltas and baselines (#3595/#3698/#3705/#3708), ScoreAll skipping amplifiers on a zero base and the non-virtual GetActiveEdges (#3688), the PgTargetFactKeys censuses (#3688), pg_total_relation_size vs the heap and the two TimescaleDB views that hide materializations and history (#3610/#3585), the -infinity crash backoff (#3629), the Slack block budget and the surrogate-pair cut (#3612/#3618/#3625/#3644), LCK_M_SCH_M folding into LCK so no SCH_M fact exists (#3709), one wait profile per run (#3709), and the CRLF round-trip. Each citation was read before it was written down; a wrong citation is worse than none. The review workflow reads this file before the PR body and must say, per trap the diff is near, whether it applies here and why. * Claude review: blind read before the body, callers rule, structured verdict with a ledger, and an Opus/Sonnet tier by changed path (#3710) The prompt runs in two phases. Phase 1 forbids gh pr view / git log / git show until the reviewer has read the diff, every changed region in context, the callers of every changed public symbol (Grep, both SKUs and both test projects) and .github/REVIEW-TRAPS.md, and written its own account. Phase 2 reads the body and grades every claim VERIFIED / UNVERIFIED / REFUTED, then reports the DIVERGENCE between the two accounts. The verdict is a fixed block -- Claims, Riskiest lines with the pinning test or UNPINNED, Traps near this diff, Divergence, Reviewer checklist -- ending in one machine-readable ledger line, posted through gh pr review --body-file - with a quoted heredoc (no Write tool; the allowlist is unchanged and still read-only). The #3650 verdict check now fetches the newest verdict in the window by id and fails the job when the body lacks the "## Verdict:" header or the ledger, or when the ledger's verdict disagrees with the review's API state (a "CHANGES REQUESTED" body submitted with --comment is the shape the guard would wave through). The guard's own arm is untouched and still keys on the state. A dorny/paths-filter@v4 step (the repo's pin style) routes changes under the shared libraries, Darling Storage/Analysis/Service, Lite Services/Mcp/Database, install/ and every .sql file to the full tier -- claude-opus-5[1m], 60 turns -- and everything else to claude-sonnet-5 at 30 turns; one decision step emits tier/model/max_turns, one review step consumes them (two steps would double the "review posted" accounting the guard does), and the prompt carries the tier and model into the ledger. A filter that dies degrades to FULL, not light. Step name "Claude review" and the ALWAYS POST sentinel are unchanged: both are read by claude-review-guard.yml. * Review tier: Alerting and Notifications read at the full tier (#3710)
The lie, precisely
get_store_metrics'job_historyblock reportsrecording: true. That is the #3175 guard, and it is true: it reads the effective value oftimescaledb.enable_job_execution_loggingand the GUC is on. But it answers "is the instrument switched on", and the reader who follows the tool's own redirect totimescaledb_information.job_historyis asking "will I see its rows". Between those two questions sits a filter nothing on this surface mentioned. The view's definition on TimescaleDB 2.28.1 ends with:A row is visible only to a member of the database owner's role or of the job's owner role. Jobs are owned by whoever created them (
bgw_job.owner regrole NOT NULL DEFAULT current_role), which for every policy this product adds is the service's owner role. The base table is not a back door —pre_install/tables.sqlends withREVOKE ALL ON _timescaledb_internal.bgw_job_stat_history FROM PUBLIC. And the trap is that the two views a reader checks first —timescaledb_information.jobsandjob_stats— have noWHEREclause at all. A role that can see all 110 jobs and every one of theirtotal_runsreasonably assumes it can see their history, reads zero rows, and concludes recording is broken. Measured on a production store this morning: the GUC effectiveon, all 110 jobs owned by the service's owner role, two independent reads as the least-privilegeadminrole counted 0 rows ever, and the owner role's read of the same view returned every hourly run. A stuck-then-recovered compression job's run rows were declared unknowable in a real postmortem while they sat in the table.Recording was never broken. The GUC never lied. The block never said whose eyes the rows are visible to, and never proved rows exist.
What the block says now, and why each field
The reader gains a second statement,
DarlingStoreMetricsReader.JobHistoryEvidenceSql, read after the GUC probe on its own checkout and failure-isolated on its own — the two fail independently, so a count that times out cannot make the GUC read unknown. It is not attempted where the GUC probe said the view does not exist (plain PostgreSQL →NotApplicable, not a fault-shapedUnreadable). In one round trip it returns:reader_role—current_user. Every count that follows is a count through that role's eyes, because the view decides per role what it shows.reader_is_database_owner_member,job_count,history_visible_job_count,visibility— the view's own twopg_has_roletests, evaluated live for this reader (database-owner membership, and per-job owner membership from the unfilteredjobsview), and the verdict derived exactly as the view combines them:All(a census),Partial(a census of the jobs this reader owns, stated as a fraction),None(the view shows this connection nothing by construction).Unknownwhen nothing was observed or there are no jobs to be an owner of.rows_observed,observed_window_hours,newest_row_at— what this reader actually saw: history rows with a start inside a fixed 24-hour window, and the newest start it was shown over all time (null when it was shown none — a count and a nullable are two different facts, and the whole issue is one being read as the other).jobs_run_in_window,newest_run_started_at— the population half, from the unfilteredjob_stats: how many jobs started a run inside the same window. A zero is readable only when the instrument would have caught the event AND the event had a chance to occur; this is the second condition, measured rather than assumed.contradiction— the new finding class as one bool, true only when all four conditions hold: the GUC says recording, the reader's visibility isAll, jobs started runs in the window, and the view showed no rows.evidence—Observed/Unreadable/NotApplicable, so a count this read did not obtain never reads as zero.Why 24 hours and not
days_back. The question is current — is the instrument writing now — and a 30-to-400-day forecasting window would let ten days of rows written after a heal hide a recording that stopped yesterday. Every job this product schedules runs at least daily (CAGG refreshes and the compression tick hourly, retention daily), so any 24-hour window on a live store contains starts, which is what letsjob_statsprove the population half. And the view's own Job History Log Retention Policy drops rows after one month by default, so a window past that would count a table the retention job had already trimmed and call the trimming "no rows".Why the bind is
timestamptzwithKind=Utc, explicitly. This is the inverse of the store's naive-UTC discipline, on purpose:bgw_job_stat_history.execution_startandbgw_job_stat.last_startare declaredTIMESTAMPTZin TimescaleDB's own catalog, so here the naive bind would be the bug — the parameter would be converted at the session zone and the window would skew by the host's offset. The type is stated rather than inferred so a caller's wrong Kind cannot quietly flip it.TimescaleDB inserts the history row at job start when the GUC is on (
bgw_job_stat_history_mark_start, 2.28.1) and updates it at finish, so a run that started inside the window while logging was on has a row inside the window — no waiting on a finish. Failures are written regardless of the GUC (the same file'sbgw_job_stat_history_update), which the GUC-off arm of the note now states so a non-zero count with the GUC off is read as failures, not as logging being secretly on.The note names the rule, and the contradiction arm is loud
Every arm on which the view exists — including the GUC-off arms, because a reader who heals the GUC and then checks as the wrong role walks into the same trap one step later — now carries: history rows are visible only to members of the job's owner role or of the database owner (with the predicate quoted), while
jobsandjob_statsshow every role every job, so a zero from a role outside those memberships contradicts nothing. Then, per what the evidence established about this reader:The #3175 arms (On, Off by default, Off by explicit override, NotRegistered, Unreadable) are intact and still pinned distinct.
The finding the lane made on the way in, and why it shaped the design
The issue's fix shape assumed the block could count through "the service's own (owner-member) connection". In managed mode it cannot.
DarlingMcpHostServicebuilds the MCP pool from the dedicated least-privilegemcprole —DarlingManagedRolesgrants it SELECT and a few narrow writes, never membership in the owner — so on every managed store this very connection is exactly the reader the view filters out. A barecount(*)would have reportedrows_observed = 0on every managed store and manufactured the false contradiction the issue is about. That is why the read evaluates the predicate for itself rather than counting and assuming, and whyvisibilityis a first-class field: on a managed store the block now saysNone, says the zero is the filter and not the table, and names the role that can see — which is the sentence that would have ended this morning's postmortem in a minute. On a bring-your-own store whose connection string is the owner role,visibilityisAlland the flag is fully self-proving.Making the managed block self-proving too needs owner-pool evidence — the hourly
StoreSelfMetricssweep (owner pool) persisting the owner's count into the series as a newobject_kind, or a worker-published in-memory reading. Both change an on-disk or cross-component contract and neither is taken here; the block is honest about what it can and cannot see, and the choice is left open in the report on the issue.What this does NOT do
SECURITY DEFINERwrapper is created, themcprole's surface is exactly what it was. The fix is that the block reports the filter with a measurement, not that it bypasses it.recording: truea measurement on a managed store — it says, in the field and in the note, that it is a GUC echo there and why.CHANGELOG.md(entry proposed separately).Elsewhere in the diff
TimescaleSupport.cs(HeaviestHourlyRefreshObservedCeilingSecondsdoc): the sentence "No SHIPPED read touchesjob_history" was true until this PR; it now says no shipped read takes a duration from it, and names this count as the one shipped read that touches the view and evaluates its filter.Darling/README.md(Background workers → after thejob_statscheck): a paragraph telling an operator, before they readjob_historyby hand, who it shows itself to and which role to read as.DarlingMcpInstructions.cs(store self-metrics paragraph): the per-run route is named as ownership-filtered and the block's new fields are pointed at.mcpfact, asserted together with the shipped SQL.Tests (first execution is CI — this Mac cannot run
net10.0-windows;dotnet buildof Darling.Tests and the Viewer is clean at 0 warnings)DarlingMcpStoreMetricsToolsTests: the evidence SQL pins (bothpg_has_roletests in the view's terms, all three views, one bind applied to both halves, the-infinityNULLIF, the 24-hour constant); the explicittimestamptz/Kind=Utcbind as a source pin;Visibilityderived as the view combines its tests (db-owner short-circuit, None, Partial, All-by-owners, Unknown on no jobs / not observed); the contradiction's four conditions with each flipped alone; the note distinct per evidence shape with the rule and the reader on every arm,CONTRADICTIONonly where all four hold,NotApplicableadding no text; failure isolation without a store (aPort=1data source → evidenceUnreadablewhile the GUC reading it was handed is untouched, and a pre-cancelled token provingNotRegisteredreturns before touching the connection); the description asserted with the shipped predicate.DarlingStoreMetricsJobLoggingLivePostgresTests(gated onDARLING_TEST_PG): the evidence probe against a live TimescaleDB, inserting nothing and pinning no count — the null-versus-zero contract, cross-checked against direct reads of the same views, andVisibility == Allwith the visible count equal to the job count whenever the connection is a database-owner member.