fix: #313 — Spec: health-check integrity — single-source status enum, hardened migration, cycle reconciliation, honest MCP counters - #314
Conversation
Pins health-check integrity before any implementation:
- health-schema-integrity: single-source status enum, DDL generator,
insertability probe, explicit-column migration, loud failure path,
counters table
- health-uptime-buckets: per-status uptime bucket membership incl. the
rate_limited exclusion
- health-persist-isolation: HTTP 406 diagnostic, per-row persist
isolation, rejected write counted as a persist failure not a probe error
- health-cycle-reconciliation: per-protocol bucket identity, unaccounted=0,
cycle summary persisted to counters
- mcp-counters: lifetime counter vs 90d window, seeding, prune semantics
- digest-health-mcp-fields: digest health section + renamed MCP fields
The 406 test fails with the production error itself:
CHECK constraint failed: status IN ('healthy', ..., 'method_not_allowed')
[skip ci]
…ration, cycle reconciliation, honest MCP counters (#313) Part 0 — shared infrastructure - HEALTH_CHECK_STATUSES is the only definition of the enum, now including not_acceptable. The inline CREATE TABLE, the migration DDL, and test/helpers/test-db.js all derive from healthChecksTableDDL(). - New counters table: durable, never-pruned KV aggregates, transactional with the writes they count and visible from both the server and scripts/healthcheck.js. Part A — enum, migration, uptime, failure isolation - migrateHealthChecksStatusConstraint detects by positive insertability probe (real transaction-scoped parent row, BEGIN...ROLLBACK) instead of DDL substring matching; prunes to retention and checks for 2x free space before starting; copies with explicit column lists on both sides; runs foreign_key_check inside the transaction so any failure rolls back with the original table intact. - Failure is loud: runHealthChecksSchemaGuard logs at error level and sets health_schema_invalid=1, surfaced by the digest. The console.warn swallow is deleted. - Uptime buckets pinned per status. rate_limited is excluded from numerator and denominator. - persistHealthResult calls are isolated per row: one rejected write cannot abort the URL's remaining rows or the cycle. Failures increment a DB counter, are logged in their own category with service id and attempted status, and retry next cycle. - 406 rows carry a diagnostic error_message. classifyHealthStatus and test/health-classifier.test.js are untouched. Part B — reconciliation, no probing changes - Per-protocol buckets (probed by result status incl. unknown/error, sibling_updated, skipped_unprobeable, excluded_inactive, persist_failed) sum to all rows carrying the protocol, with unaccounted pinned at 0. Rows hard-deleted mid-cycle are reported as vanished_mid_cycle rather than pushed into a negative residual. - Written to counters.last_health_cycle at cycle end; both callers report the same formatCycleSummary() line. getServices selection is unchanged. Part C — honest MCP counters - mcp_queries_lifetime is incremented transactionally in logQuery, seeded once from the 90-day floor with mcp_counter_seeded_at exposed. Window fields renamed mcp_queries_90d / mcp_active_days_90d; mcp_active_days emits the 90d value for one release with mcp_active_days_deprecated: true. Expected one-time effect: the ~10 endpoints affected by the rejected 406 writes start recording down-bucket rows, so their uptime and reliability drop and one burst of ~10 service.health_changed events fires on the first post-deploy cycle. That is the truth arriving; no suppression code was added. Reconciliation adds three GROUP BY queries over services (~1.2k rows) and one counters write per cycle — under 0.1% of a cycle that runs in minutes, well inside the 10% budget. BEHAVIOR-CHANGE: rate_limited excluded from the uptime denominator — a 429 is our prober being throttled, not provider downtime; pinned by one test per status. ASSERTION-REFACTOR: corrected two fixtures in the new tests from this issue's test commit — a rebuild fixture row dated outside retention (pruned before the assertion could read it) and a pruneQueryLog(0) call that is a no-op at second granularity; the counters retention guard now targets age-based deletes instead of any DELETE.
|
[CHANGES_REQUESTED] ## Security Review: CHANGES REQUESTED I reviewed the full diff (2,470 lines across 13 files), read the modified source files in full, checked the linked spec (#313) point-by-point, and ran the branch locally under Node 22 in an isolated worktree. Verification performed:
Findings[High] Reconciliation buckets are read at cycle end but the probe set is snapshotted at cycle start —
|
[High] Reconciliation is now a partition of a cycle-start snapshot. denominator/excluded_inactive/skipped_unprobeable came from GROUP BY queries run after the cycle, while the probe set was snapshotted at cycle start. The pollers share the health cycle's hourly interval and insert with status defaulting to 'active', so an insert mid-cycle pushed unaccounted to +1 and a deactivation mid-cycle put the same row in both probed and excluded_inactive (unaccounted -1). runHealthChecks now snapshots every services row before dispatch and buckets that fixed id set by precedence (probed > sibling_updated > persist_failed > inactive > unprobeable), so no row lands in two buckets. added_mid_cycle joins vanished_mid_cycle as the symmetric counterpart, per protocol and per cycle; neither is folded into the denominator. The unprobeable flag now mirrors getServices' predicate exactly instead of matching only probe_status='unprobeable'. [Medium] health_schema_invalid no longer latches on a transient DB error. probeHealthCheckStatuses retries SQLITE_BUSY/SQLITE_LOCKED and raises SchemaProbeUnavailableError when it cannot reach a verdict; only a non-empty rejected list sets health_schema_invalid. An indeterminate probe writes health_schema_probe_error (surfaced in the digest) and is cleared on the next determinate boot, so scripts/healthcheck.js booting against a mid-write server cannot raise a schema alarm that survives until the next deploy. [Low] One MCP predicate for the lifetime increment and the window queries. The JS half was case-sensitive includes() while SQL LIKE is case-insensitive for ASCII, so 402Index-MCP counted in mcp_queries_90d but never in mcp_queries_lifetime. Both halves now lowercase via the exported MCP_USER_AGENT_SQL, which the digest's mcp_queries_today also uses. [Low/informational] mcp_counters_ua_attested: true added to the digest traffic payload and documented in CHANGELOG — the counter is gated only on a client-controlled User-Agent and is never pruned, so it is a ceiling, not a measurement. Also tightened the counters-retention guard test, which the reviewer noted was narrower than its name: every DELETE against counters must now be key-scoped, not merely free of datetime(). Tests: 15 added, all verified failing against the previous head first (the two race tests reproduce the reviewer's exact numbers: denominator=2 on insert, excluded_inactive=1 on deactivate). Full suite 2088 pass / 0 fail / 5 skipped; eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All four findings are addressed in a single commit on What changed[High] Reconciliation is now a partition of a cycle-start snapshot. This was the real defect: three [Medium] The schema alarm no longer cries wolf. [Low] One MCP predicate. Both halves lowercase via the exported [Low/informational] I also tightened the counters-retention guard you flagged as narrower than its name — every Verification15 tests added, each confirmed failing against the previous head first. The two race tests reproduce your exact numbers before the fix — Two notes on the environment, neither affecting the result: the default I have not pushed. Your instructions said to push to the existing branch, but the revision-mode contract I operate under says to commit and let the dispatch script push — so I stopped at the commit rather than guess. |
|
[CHANGES_REQUESTED] Full-suite verification is back and confirms the branch is green. Here is my final review. Security Review: CHANGES REQUESTEDI reviewed Adversarial verification of the revision's central claimThe prior review's High finding was that the buckets weren't a partition. The revision rebuilt them over a cycle-start snapshot. I attacked that from three directions; all three hold:
One residual, theoretical and unproven: FindingsBoth are unmet #313 spec requirements. Neither requires a code change — I have no security objection to the implementation. [Low] Spec constraint unmet: no cycle-duration baseline measured or statedFile: PR description / [Low] Spec Part A.7 unmet: expected one-time production effect not documentedFile: PR description / [Informational — pre-existing, not introduced here] FK-swallow means a probed row can report
|
Summary
Automated fix for #313: Spec: health-check integrity — single-source status enum, hardened migration, cycle reconciliation, honest MCP counters
What changed
Both commits are in place on
fix/issue-313. Summary:Commit 1 —
test: add failing test for #313(63 new tests, all RED first)health-schema-integrity— single-source enum, DDL generator, insertability probe, explicit-column migration, loud-failure path, counters tablehealth-uptime-buckets— one test per status bucket, incl. therate_limitedexclusionhealth-persist-isolation— 406 diagnostic, per-row isolation, rejected write ≠ probe errorhealth-cycle-reconciliation— bucket identity,unaccounted = 0, cycle summary persistencemcp-counters/digest-health-mcp-fields— lifetime vs 90d window, seeding, prune semantics, digest fieldsThe 406 test failed with the production error itself:
CHECK constraint failed: status IN ('healthy', …, 'method_not_allowed').Commit 2 —
fix: …(#313)HEALTH_CHECK_STATUSES(+not_acceptable) is the only definition; inline DDL, migration, andtest/helpers/test-db.jsall derive fromhealthChecksTableDDL(). New never-prunedcounterstable.BEGIN…ROLLBACKwith a real parent row; prunes + checks 2× free space; explicit column lists;foreign_key_checkinside the transaction so failures roll back intact;health_schema_invalid=1+ error-level logging replaces theconsole.warnswallow. Uptime buckets pinned; per-row persist isolation with a DB-backed counter and its own log category; 406 diagnostic.classifyHealthStatusandhealth-classifier.test.jsuntouched.unaccountedpinned at 0, written tocounters.last_health_cycle, one sharedformatCycleSummary()for both callers.getServicesselection unchanged.mcp_queries_lifetimeincremented transactionally inlogQuery, seeded once from the 90-day floor; window fields renamed;mcp_active_daysdeprecated-but-emitted.Verification: 2075 tests, 0 failures, eslint clean. Note:
npm --prefix mcp-server testcan't run in this worktree (@types/nodenot installed there) —mcp-server/is untouched by this change.Two things to flag:
vanished_mid_cyclecounter so a service hard-deleted mid-cycle (the hourly purge overlaps health cycles) is excluded from the buckets instead of drivingunaccountednegative. Without it, a routine purge would break the acceptance criterionunaccounted = 0and make that metric noise. I verified the test for it fails when the filter is removed.Linked Issue
Closes #313
Closes #309
Dispatched by cc-dispatch.sh at 2026-07-26 14:45:19