Skip to content

get_store_metrics' inventory was blind to 57% of the store it reported on: it now enumerates the continuous aggregates and the named plain tables, reconciles every sweep against pg_database_size and states its coverage, and the owner's job_history count rides the sweep so the managed-mode block is a measurement too (#3582, #3574) - #3610

Merged
erikdarlingdata merged 2 commits into
devfrom
fix/3582-lane
Sep 18, 2026

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

The lie, in one number

On the largest production store, get_store_metrics' object inventory accounted for 159 GiB of a 415 GiB database — 38% — and answered "here is the store" with it. A growth investigation that trusted the tool went looking for the missing 256 GiB inside the one object the tool did measure correctly. The bytes were in two places the walk could not see. The twenty continuous-aggregate materializations, ~235 GiB (57%), because timescaledb_information.hypertables never lists a materialization: the view's own definition on 2.28.1 ends WHERE ht.compression_state <> 2 AND ca.mat_hypertable_id IS NULL. (The issue's stated mechanism — that the view lists them under _materialized_hypertable_N names and the sweep's walk keys on collector tables — is wrong on both counts; the walk has no filter at all and the view has no such rows. The effect is exactly as reported.) And the product's own plain tables the walk never named: collect.query_store_text at 15 GiB — V74 stores statement text INLINE by design — collect.query_store_plan_map, and config.config_alert_log.

A correct query producing a wrong conclusion because the envelope never stated its coverage. Same family as #3541.

What the sweep writes now

Every row is under the one metric_time a run stamps, so a run's rows join. Four new kinds, each a named constant (StoreSelfMetrics.*ObjectKind), because a drifted spelling returns zero rows to every reader rather than an error:

  • continuous_aggregate — one row per aggregate in timescaledb_information.continuous_aggregates, under its user-facing view name, sized through its materialization: hypertable_detailed_size on materialization_hypertable_schema.materialization_hypertable_name for the total, chunk_compression_stats on the same regclass for pre/post bytes, and the chunk count from timescaledb_information.chunks — NOT a count over chunk_compression_stats, which returns zero rows for a hypertable whose compression is off (measured: a two-chunk materialization yielded no rows until compression was enabled). hypertable_detailed_size on a materialization is byte-exact against pg_total_relation_size over root + chunks + compressed chunk relations (measured: 155,648 = 24,576 + 49,152 + 81,920).
  • table — collect.query_store_text, collect.query_store_plan_map, config.config_alert_log: pg_total_relation_size plus the planner's reltuples estimate as row_count. An estimate, deliberately, and the doc says why the two kinds differ: a dim's heap is small (its bytes are TOAST), so the dim arm's exact count(*) is cheap; query_store_text's heap IS the 15 GiB, and an exact count would be a 15 GiB read every hour for a figure whose job is "roughly how many statements have text". -1 (never analysed) maps to NULL, not to a count of minus one. Schema-qualified names, unlike every other kind, because the kind spans two schemas and shares its population with the live un-enumerated census, which names relations schema.relation.
  • other — the sum of pg_total_relation_size over every user-schema relation (relkind r/m/p/S, NOT relisshared) that no named row accounts for, with the relation count in chunk_count (the pieces the sum spans, which is what the column means on a hypertable row too). What is excluded is exactly what other rows already size: the dims and named tables by (schema, relation), every hypertable root the hypertables view lists, every materialization root the aggregates view names, and every chunk relation in _timescaledb_catalog.chunk — joined by NAME, never by a regclass cast a vanished relation would make raise. So the statement sizes a few hundred relations, not ~40,000 chunks, and its cost does not follow the chunk count.
  • system — the same census on the other side of the user/system line: pg_catalog, information_schema, and every _timescaledb_* schema minus the chunks. Two rows and not one, because folding them would inflate "relations the product should know by name" with sixty pg_catalog tables, and leaving them out would surface as an unreconciled gap of a percent or more on every store every hour — a false finding of exactly the shape this work exists to stop. NOT c.relisshared is load-bearing: the shared catalogs live in global/, outside pg_database_size, and summing them made the rig census EXCEED the database by 512 KiB — wrong in the direction nobody checks.

Both catch-all rows coalesce(..., 0): an empty bucket is a ZERO row, so an absent row means the statement did not run, never that there was nothing to count. A plain-PostgreSQL variant omits the TimescaleDB anti-joins (those catalogs do not exist there); on such a store the collector tables are ordinary tables and land in other, and the note says so in those words rather than flagging a low share.

The reconciliation

DarlingStoreMetricsReader.ComputeInventory — pure — takes the rows that share the store row's metric_time (the same sweep; a sweep that died half-way leaves older rows behind for the kinds it never reached, and summing those against a newer database figure would manufacture a gap) and states two coverages separately: enumerated (bytes under named objects — the issue's "inventory covers N%") and attributed (bytes any row explains). residual_bytes = pg_database_size − attributed; reconciled when both catch-all rows are present and the residual is inside the larger of 1% and 64 MiB. The floor is there because on a fresh rig the residual was 161,471 bytes on a 17 MiB database — exactly pg_database_size minus the sum of every local relation, i.e. pg_internal.init, pg_filenode.map, PG_VERSION — already 0.9% of that store and fixed per database. Rows from an older sweep are counted (stale_object_rows) and the note says the sweep is not completing.

The inventory block is on every response: database_bytes, enumerated_bytes/percent, attributed_bytes/percent, residual_bytes, tolerance_bytes, reconciled, stale_object_rows, bytes_by_kind (where "what is driving growth" is one glance — the 57% would have been the first line), unenumerated {total_bytes, relation_count}, system {...}, largest_unenumerated (live top 10 by name, over the SAME census fragments the sweep sums with, so the list and the number cannot disagree), aggregate_state, and a note in the issue's words: "named rows account for 409.0 GiB of the 415.0 GiB database (98.55%) … 3.0 GiB sits in 14 un-enumerated user-schema relation(s), the largest being … RECONCILED: every row together accounts for 99.76% …" — or "NOT RECONCILED — a finding: 10.0 GiB of pg_database_size is attributed to NO row of this inventory".

Each continuous_aggregate object carries, read LIVE from the catalog (the #2813 precedent for state the series does not carry), compression_enabled, materialized_only, source (a hierarchical aggregate's source resolved back to its parent's view name), and refresh_policy_job_id / compression_policy_job_id / retention_policy_job_id (null where none exists) — the three facts #3581 assembled by hand. timescaledb_information.jobs reports a policy on an aggregate under the aggregate's view name (its own COALESCE(ca.user_view_schema, ht.schema_name)), which is what the lookups join on; the compression predicate carries the 2.18+ columnstore hedge the rest of the codebase uses. The note adds "N of M continuous aggregate(s) have compression DISABLED and hold X GiB between them" when true. These are STATE, not series, and the table has no column that could hold a bool without bending a byte column's meaning — so they are read live and no migration rung is taken.

The managed-mode self-proof (#3574 follow-up, ruled yes)

#3585 made the job_history block say visibility: None in managed mode — the MCP host reads as the least-privilege mcp role, which the view's ownership filter shows nothing — but it could not COUNT. The hourly sweep runs on the worker's owner pool, the role that created every policy job, so it can. StoreSelfMetrics.JobHistoryInsertSql embeds the evidence SELECT verbatim (the string moved to StoreSelfMetrics.JobHistoryEvidenceSql; DarlingStoreMetricsReader.JobHistoryEvidenceSql is now an alias, so every #3585 pin still holds and there is one predicate, not two) and persists an object_kind = 'job_history' row: object_name is the role that counted; row_count is rows_observed ONLY when the view admits that role to every job's history (the two pg_has_role tests, in SQL) and NULL otherwise, because a count the filter truncated is not a count; total_runs is the unfiltered population half; schedule_interval_ms is the window width computed from the two binds; last_run_duration_ms is the newest row's age at the sweep in milliseconds. That last one is the single overload that bends a column's name. It is taken rather than a rung because the store has no timestamp column besides metric_time, V127 is claimed today, and the reader decodes it back into an absolute instant before anyone reads it — the mapping is documented once on the StoreSelfMetrics class summary, and a dedicated column is the clean follow-up. $1 is the window start (timestamptz, Kind = Utc, the #3574 inverse-discipline bind) and $2 metric_time — the one arm where $1 is not the stamp, and the doc says why.

OwnerJobHistoryEvidence.FromLatest (pure) decodes the row into Observed / Stale / Filtered / Absent. Stale is older than 3 hours — the first whole hour past the 2.47 h largest-observed sweep gap the LatestStoreSizeSql paragraph documents; a row that old means two consecutive sweeps did not land and its window no longer speaks for now, so it is shown with its age and no verdict is judged from it. The block gains owner_evidence, owner_role, owner_observed_at, owner_observed_age_hours, owner_observed_window_hours, owner_rows_observed, owner_newest_row_at, owner_jobs_run_in_window; contradiction is true from EITHER admitted reader's numbers; and the note — appended only where this connection is not itself All — says where the number came from: "THE OWNER'S OWN COUNT: the service's hourly self-metrics sweep runs on the owner pool … Reading as 'darling' at …, the sweep saw 2 row(s) … 'recording' is therefore a measurement on this store after all — the service's, taken hourly as the owner — not a GUC echo." Or the CONTRADICTION from the owner's numbers, with the same benign cause and the same way to settle it. Every #3585 field and arm is intact; the two-argument JobHistoryNote is the three-argument one with Absent.

Proven live on the rig as an unprivileged role: visibility: None, rows_observed: 0 beside owner_evidence: Observed, owner_rows_observed: 2 after the GUC was turned on and one job ran — and owner_rows_observed: 1 before, the one row being the telemetry job's failure written with the GUC off.

Two truth fixes

  1. The job_history is silently empty on clusters predating ~2026-08-17, because its GUC lives in the one postgresql.conf block EnsureConfAppended cannot heal #3175 GUC-off notes said job_history "is NOT recording, so a maximum over it returns zero rows". TimescaleDB 2.28.1 writes a FAILED run's row regardless of the GUC (job_stat_history.c gates only the success path; measured: with the GUC off, the telemetry job's one failure was the view's one row). Both Off arms now say the view is recording FAILED runs only, a maximum over it is a census of failures and not of runs, an empty result means no failure was recorded, and a non-empty one is not a sign logging is secretly on — which is what the visibility arm appended right after them already (correctly) said. The reader's JobExecutionLoggingSql doc and the Off enum doc carry the same correction; the tool description's "ONLY while … is on" sentence too.
  2. Darling/README.md called the mcp role "dormant until MCP is exposed on the LAN". DarlingMcpHostService builds the MCP pool from the stored mcp-role credential whenever postgres.managed = true, loopback included. Both sentences that said otherwise (the roles table and the BYO paragraph's "the only consumer") now say what the code does; the Background-workers paragraph gains the owner's-count sentence.

What this does NOT do

Verification

Rig: Docker Desktop, timescale/timescaledb:2.28.1-pg18 (the CI recipe's versions), torn down after. The real SweepAsync against a migrated, converted, aggregate-bearing scratch store wrote 191 rows across nine kinds; the rows summed to exactly the census of every local relation, residual 161,471 bytes = pg_database_size − Σ pg_total_relation_size. The real GetStoreMetrics ran as the owner and as an unprivileged role; both shapes above are its output. The plain variant executed. Every new test's assertions were run through the shipped code in a throwaway harness before CI (this Mac cannot run net10.0-windows; CI is first execution). All four build targets 0 warnings.

Tests: StoreSelfMetricsTests — the aggregate SQL pins (materialization regclass, view name, chunks-view count, the hypertable arm's lack of any filter), the named-table pins cross-checked against the census predicate by (schema, relation), both catch-all variants over the shared fragments, the job_history arm's embedded SELECT and column mapping, the kind vocabulary distinct and written by its arms, the dialect theory over every new statement, and the live end-to-end sweep extended to assert every new kind, the reconciliation through the real reader, and the owner row decoding as Observed. DarlingMcpStoreMetricsToolsTests — the shared-SQL identity, the owner decoder's mapping and freshness bar, the owner contradiction's five flips, the owner note per shape and its exact append rule, the corrected GUC-off notes (with the On arm as positive control), ComputeInventory over one sweep (enumerated vs attributed, stale rows, the floor, missing catch-all, no store), the inventory note's five shapes, the byte formatter, the aggregate-state and top-N SQL pins, failure isolation to null (never an empty list), and the description asserted with the arms and constants behind it.

… and the named plain tables it was blind to, reconciles every sweep against pg_database_size and states its own coverage, and the owner's job_history count rides the sweep so the managed-mode block is a measurement too (#3582, #3574)

On the largest production store the inventory accounted for 159 GiB of a 415 GiB database: the
twenty continuous-aggregate materializations (~235 GiB) never appeared because
timescaledb_information.hypertables excludes them by construction (AND ca.mat_hypertable_id IS
NULL — verified on 2.28.1; the walk itself has no collector filter), and the product's own plain
tables (collect.query_store_text at 15 GiB, query_store_plan_map, config.config_alert_log) were
never named. The sweep now writes one continuous_aggregate row per aggregate under its view name,
sized through its materialization (hypertable_detailed_size on the mat regclass is byte-exact
against root + chunks + compressed chunks — measured), three table rows, and two catch-all rows
(other: user-schema relations no named row accounts for; system: catalogs and TimescaleDB
bookkeeping), so every byte is attributed to some row or is an honest residual. The reader
reconciles one sweep's rows against that sweep's pg_database_size and the tool states
enumerated/attributed coverage, the residual, and whether it reconciles (1% or 64 MiB, whichever
is larger; on a fresh rig the residual was exactly the directory's non-relation files, 161,471
bytes). Aggregate compression/policy state and the largest un-enumerated relations are read live.

#3574's managed-mode half: the hourly sweep runs the SAME evidence SELECT as the owner role and
persists what it saw (object_kind job_history; the column mapping is documented once on
StoreSelfMetrics), and the block reports it beside the mcp role's own None verdict, with the
contradiction computable from the owner's numbers when the reading is fresh. Two truth fixes: the
GUC-off notes no longer claim the view "returns zero rows" (FAILED runs are written regardless —
measured), and the README no longer calls the mcp role dormant until LAN exposure (the MCP host
uses it whenever postgres.managed is true).
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 18, 2026 16:41
Comment on lines +420 to +424
/// <summary>
/// The relations some NAMED row already sizes, every store shape (#3582): the two payload dimensions
/// and the three named plain tables, by <c>(schema, relation)</c>. A relation matched here is never in
/// a catch-all row, or the reconciliation would count it twice. Names are the same compile-time
/// constants the INSERT arms interpolate, so the census and the rows it excludes cannot drift apart.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor doc accuracy nit: "Names are the same compile-time constants the INSERT arms interpolate, so the census and the rows it excludes cannot drift apart" is only true for the two payload-dimension tuples. The three plain-table tuples below (('collect', 'query_store_text'), ('collect', 'query_store_plan_map'), ('config', 'config_alert_log')) are hand-typed literals, not {QueryStoreTextStore.TableName} / {QueryStorePlanMap.TableName} / {AlertLogTable} interpolations — those constants are compound ("collect.query_store_text") so they can't be split into the (schema, relation) tuple form directly. If one of those constants' table name ever changed, this predicate wouldn't follow it automatically the way the comment implies; the only thing that would catch the resulting double-count is StoreSelfMetricsTests.TableInsertSql_NamesTheThreeProductTables_AndTheCensusExcludesExactlyThose, not the "cannot drift apart" language here. Given this PR's whole thesis is about exactly this class of silent drift, worth either softening the claim or deriving the three literals from the constants (e.g. split on . at a call site) so the comment's guarantee actually holds.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 36d458e, and the nit was the PR's own thesis applied to itself. The three tuples were hand-typed because the table constants are compound ("collect.query_store_text") and cannot be split at compile time — so rather than soften the claim, the predicate now compares the concatenated (n.nspname || '.' || c.relname) against the five constants themselves ('collect.{QueryTextDimTable}', '{QueryStoreTextStore.TableName}', '{QueryStorePlanMap.TableName}', '{AlertLogTable}', …), and the "cannot drift apart" sentence is true for all five. No product relation name contains a dot, which the doc now says. TableInsertSql_NamesTheThreeProductTables_AndTheCensusExcludesExactlyThose pins that every quoted name in the predicate is one of the five constants and nothing else; the predicate shape was re-verified on a throwaway 2.28.1 container.

@claude

claude Bot commented Sep 18, 2026

Copy link
Copy Markdown

Reviewed. This is a large, carefully self-verified change (real Docker/TimescaleDB rig measurements, extensive test coverage for both the sweep SQL and the reconciliation/decoding logic). No correctness bugs, SQL-injection surface (all identifiers come from format('%I.%I', ...) over catalog rows or compile-time constants, never free-form input), or performance regressions found. This is Darling/PostgreSQL-only functionality (hypertables, continuous aggregates, TimescaleDB catalogs) with no Lite equivalent to keep in parity — Lite's DuckDB store has no analogous concept, and the PR correctly doesn't touch it.

One left an inline nit on StoreSelfMetrics.cs (doc comment slightly overstates the drift-safety of NamedRelationPredicateSql for 3 of its 5 tuples — non-blocking, already covered by a test).

Separately: every other [Unreleased] entry in CHANGELOG.md gets a bullet for its issue number, and this PR explicitly opts out ("What this does NOT do: ... It does not touch ... CHANGELOG.md"). Given the scope here (a real production data-visibility gap, #3582/#3574), that's a notable departure from convention — worth confirming that's intentional (e.g. changelog entries added in a later batch step) rather than an oversight.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — reviewed the Darling PostgreSQL store-metrics sweep, MCP reader, and MCP tool changes (StoreSelfMetrics.cs, DarlingStoreMetricsReader.cs, DarlingMcpStoreMetricsTools.cs, tests, README/instructions) for correctness, SQL injection, Lite/Darling parity, and performance. No substantive issues found; left one non-blocking inline nit and a note about the CHANGELOG.md omission.

…st the compound constants themselves (no hand-typed tuples), the ObjectKind scanner accepts every StoreSelfMetrics.*ObjectKind const, and the two new formatters join KnownTruncatedRanges

The review's nit was right and this PR's own thesis: NamedRelationPredicateSql claimed the census
"cannot drift" from the INSERT arms while three of its five tuples were hand-typed, because the
table constants are compound ("collect.query_store_text") and cannot be split at compile time. The
predicate now compares (n.nspname || '.' || c.relname) against the five constants directly, so the
claim holds; the test pins that every quoted name in the predicate is one of them.

Two CI guards over the touched file: SerialLoopStoreSizeSourceTests' ObjectKind scanner only knew
StoreSelfMetrics.StoreObjectKind (the one kind const that existed) — its invariant is "no retyped
literal", so it now accepts any StoreSelfMetrics.*ObjectKind and rejects a StoreSelfMetrics member
that is not one (theory rows for both); two `is … or …` patterns in the tool became `==`/`||` so
the scanner reads them. TsqlConventionGuardTests.KnownTruncatedRanges gains Stamp and Window, the
two expression-bodied formatters, whose stranded literals are format strings and not T-SQL.
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

On the CHANGELOG: intentional, not an oversight. Lane PRs in this repo do not touch CHANGELOG.md; the coordinator splices one changelog PR from the entries each lane hands back (the buffer-and-splice protocol), so the [Unreleased] bullets for #3582 and #3574's follow-up land there. The entry is drafted and in this lane's report.

The two red CI guards were meta-guards over the touched file rather than behaviour (all new tests passed): the ObjectKind comparison scanner in SerialLoopStoreSizeSourceTests only knew the one kind constant that existed and now accepts every StoreSelfMetrics.*ObjectKind (its invariant is "no retyped literal"; theory rows added for a sibling const passing and a non-kind member failing), and TsqlConventionGuardTests.KnownTruncatedRanges gains the two new expression-bodied formatters, Stamp and Window, whose stranded literals are format strings — both in 36d458e, per that guard's own instruction to say which in the PR.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — reviewed the SQL (census anti-joins for the new continuous_aggregate/table/other/system rows, the JobHistoryInsertSql two-parameter bind, reconciliation math in ComputeInventory, and the owner job_history freshness/decode logic), confirmed no SQL injection surface (all new statements are compile-time consts with positional params), confirmed no Lite/Darling parity gap (this is Postgres/TimescaleDB-only catalog introspection with no DuckDB analog), and checked the new test coverage for the boundary cases (fresh/stale cutoff, filtered vs. zero, catch-all-missing, plain-PostgreSQL variant). No correctness, security, or parity issues found.

@erikdarlingdata
erikdarlingdata merged commit 4087d27 into dev Sep 18, 2026
8 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3582-lane branch September 18, 2026 17:00
erikdarlingdata added a commit that referenced this pull request Sep 18, 2026
… job_history evidence read lives on StoreSelfMetrics and has two callers, and an unlogged store still records FAILED runs (#3581 riders, #3582/#3574) (#3619)

Text only, no behavior. (1) The heaviest-refresh ceiling paragraph said the one shipped read touching job_history was DarlingStoreMetricsReader.JobHistoryEvidenceSql; #3610 moved the string to StoreSelfMetrics.JobHistoryEvidenceSql (the reader's is an alias) and gave it a second caller — the hourly self-metrics sweep runs it as the owner role and persists the count as an object_kind = 'job_history' row. (2) Both slot-watch alert texts said a store without timescaledb.enable_job_execution_logging "reports nothing there until it heals"; a FAILED run is written to job_history regardless of the GUC and only successes need it, so the lines now say a result holding no successful runs is the gap, not an empty view.
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
…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)
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