- BEHAVIORAL: Tests exercising actual code behavior (function calls, HTTP requests, data output)
- STRUCTURAL: Tests verifying code structure (source scanning, file inspection)
- INTEGRATION: Tests requiring live PostgreSQL (build-tag guarded)
Coverage Status values:
- COVERED: rule is implemented and has at least one passing test.
- PLANNED: rule is design-only at this point; implementation and tests land in a phased follow-up. The Test ID column lists the test cases the design anticipates.
| Rule ID | Rule Summary | Test ID(s) | Coverage Status | Evidence Type | Notes |
|---|---|---|---|---|---|
| R001 | PostgreSQL connection with supplied params | TC-SIG-001 | COVERED | BEHAVIORAL | Connection config validates host/port/dbname/user, application_name, default port |
| R002 | Execute only approved collector queries | TC-SIG-002, TC-SIG-003 | COVERED | BEHAVIORAL | Linter rejects DDL/DML/dangerous functions; accepts valid SELECT |
| R003 | Collect diagnostic data from PG sources | TC-SIG-004, TC-SIG-005 | COVERED | BEHAVIORAL | Catalog contains 9+ required query IDs; sorted output verified |
| R004 | Structured snapshot format (NDJSON) | TC-SIG-006, TC-SIG-007, TC-SIG-023 | COVERED | BEHAVIORAL | Encode/decode roundtrip; compression at 4096 threshold; data types preserved |
| R005 | Snapshot metadata present | TC-SIG-008 | COVERED | BEHAVIORAL | Export produces metadata.json with schema_version, collector fields, RFC3339 timestamp |
| R006 | Package snapshots into ZIP | TC-SIG-009, TC-SIG-024, TC-SIG-128, TC-SIG-129 | COVERED | BEHAVIORAL | ZIP contains required files; omits analyzer-only files; default no-data export is refused (no false-clean, R125); every emitted ZIP carries collection_status |
| R007 | No scoring, recommendations, LLM | TC-SIG-010, TC-SIG-011, TC-SIG-012 | COVERED | STRUCTURAL | Source scan confirms no analysis/scoring/LLM modules present |
| R008 | No external AI network calls | TC-SIG-013 | COVERED | STRUCTURAL | Source scan confirms no LLM transport code |
| R009 | Suitable for OSS release | TC-SIG-014 | COVERED | STRUCTURAL | LICENSE present; no proprietary markers in source |
| R010 | Stable CLI with commands | TC-SIG-015 | COVERED | STRUCTURAL | CLI help output includes required commands |
| R011 | HTTP API endpoints (see Appendix A) | TC-SIG-016, TC-SIG-017, TC-SIG-041 | COVERED | BEHAVIORAL | /health 200 no auth; /status fields + no secret fields; /collect/now 202; /export ZIP |
| R012 | Per-query and per-target timeouts | TC-SIG-018 | COVERED | BEHAVIORAL | Default timeouts verified; timeout options override correctly |
| R013 | Three-layer read-only enforcement | TC-SIG-019 | COVERED | BEHAVIORAL + STRUCTURAL | Linter tested (10 cases); session param verified; ReadOnly tx verified |
| R014 | Version and extension filtering | TC-SIG-020 | COVERED | BEHAVIORAL | Extension exclusion/inclusion; sorted output |
| R015 | Cadence-based scheduling | TC-SIG-021 | COVERED | BEHAVIORAL | Duration constants; due-selection behavior; no catch-up |
| R016 | Credentials never cached/exported | TC-SIG-022 | COVERED | STRUCTURAL | No password column in schema; no Password field in storage types |
| R017 | Session read-only guard | TC-SIG-025 | COVERED | BEHAVIORAL + STRUCTURAL | Connection config sets read-only param; verified on acquired connection |
| R018 | Refuse superuser role | TC-SIG-026, TC-SIG-034 | COVERED | BEHAVIORAL | Superuser hard failure blocks; default is blocking |
| R019 | Refuse replication role | TC-SIG-027 | COVERED | BEHAVIORAL | Replication hard failure blocks |
| R020 | Refuse bypassrls role | TC-SIG-028 | COVERED | BEHAVIORAL | BypassRLS hard failure blocks |
| R021 | Read-only transaction enforcement | TC-SIG-025 | COVERED | BEHAVIORAL + STRUCTURAL | Dedicated connection; SET LOCAL timeouts inside transaction |
| R022 | Session timeout enforcement | TC-SIG-029 | COVERED | BEHAVIORAL + STRUCTURAL | Default timeouts; SET LOCAL verified; lock_timeout=5000 |
| R023 | Hard vs soft failure distinction | TC-SIG-030 | COVERED | BEHAVIORAL | Warnings do not block; hard failures do |
| R024 | No secrets in logs/API/export | TC-SIG-031, TC-SIG-041 | COVERED | BEHAVIORAL | DSN redaction; /status excludes secret fields |
| R025 | Actionable error messages | TC-SIG-032 | COVERED | BEHAVIORAL | Error contains remediation guidance |
| R026 | Unsafe override model | TC-SIG-033, TC-SIG-034, TC-SIG-042 | COVERED | BEHAVIORAL | Override option; default false; export metadata with bypass reasons |
| R027 | Configuration via YAML + env vars | TC-SIG-040 | COVERED | BEHAVIORAL | YAML loading, env override precedence, default values |
| R028 | Config file search order | TC-SIG-040 | COVERED | BEHAVIORAL | Defaults applied when no file found |
| R029 | Single-target container mode via env | TC-SIG-040 | COVERED | BEHAVIORAL | SIGNALS_TARGET_* creates target with correct fields and defaults |
| R030 | Config validation at startup | TC-SIG-040 | COVERED | BEHAVIORAL | Validate catches short interval, zero retention, empty fields, multiple secrets, bad durations |
| R031 | Initial forced collection | TC-SIG-037 | COVERED | BEHAVIORAL | First cycle fires immediately; collect_error event proves execution |
| R032 | Overlap prevention | TC-SIG-038 | COVERED | BEHAVIORAL | Rapid CollectNow calls do not block (buffered channel dedup) |
| R033 | Concurrent multi-target collection | TC-SIG-039 | COVERED | BEHAVIORAL | 3 targets with maxConcurrent=2; all 3 attempted; errors per target |
| R034 | Commit failure blocks persistence | TC-SIG-036 | COVERED | STRUCTURAL | Commit error checked; return precedes downstream persistence |
| R035 | Export metadata contract | TC-SIG-042 | COVERED | BEHAVIORAL | Export metadata contains unsafe_mode and dynamic bypass reasons |
| R036 | Persistence guarantees | TC-SIG-043 | COVERED | BEHAVIORAL | Migration creates tables; instance ID stable; retention cleanup; batch insert atomic |
| R037 | Dynamic column capture for version-sensitive views | TC-SIG-044 | COVERED | BEHAVIORAL | pg_stat_statements uses SELECT *; linter accepts; NDJSON preserves all dynamic columns including future/renamed ones |
| R038 | Query failure isolation | TC-SIG-045 | COVERED | BEHAVIORAL + STRUCTURAL | Savepoint isolation in collector; ROLLBACK TO SAVEPOINT on failure; transaction not aborted |
| R039 | Dynamic capture preserves safety model | TC-SIG-046 | COVERED | BEHAVIORAL | Dynamic query passes linter; no write keywords; extension gating preserved |
| R040 | Server identity collection | TC-SIG-047 | COVERED | BEHAVIORAL | Registered, passes linter |
| R041 | Extension inventory | TC-SIG-047 | COVERED | BEHAVIORAL | Registered, passes linter |
| R042 | Checkpoint/bgwriter health | TC-SIG-047 | COVERED | BEHAVIORAL | Registered, passes linter |
| R043 | Long-running transactions | TC-SIG-047 | COVERED | BEHAVIORAL | Registered, passes linter |
| R044 | Lock-blocking chains | TC-SIG-047 | COVERED | BEHAVIORAL | Registered, passes linter |
| R045 | Login roles inventory (no password hashes) | TC-SIG-047 | COVERED | BEHAVIORAL | Registered, passes linter, no pg_authid/password access |
| R046 | Connection utilization | TC-SIG-047 | COVERED | BEHAVIORAL | Registered, passes linter |
| R047 | Planner stats staleness | TC-SIG-047 | COVERED | BEHAVIORAL | Registered, passes linter |
| R048 | pg_stat_statements reset check | TC-SIG-047 | COVERED | BEHAVIORAL | Registered, requires extension, gracefully skipped when absent |
| R049 | Replication slot risk | TC-SIG-048 | COVERED | BEHAVIORAL | Registered, no extension required, graceful empty result when no slots |
| R050 | Replication status/lag | TC-SIG-048 | COVERED | BEHAVIORAL | Registered, graceful empty result when no replicas |
| R051 | Checkpointer stats (PG 17+) | TC-SIG-048 | COVERED | BEHAVIORAL | Registered, MinPGVersion=17 verified, excluded on PG 16 |
| R052 | Vacuum health diagnostic | TC-SIG-048 | COVERED | BEHAVIORAL | Registered, includes dead_pct/xid_age/reloptions, adds value over raw stats |
| R053 | Idle-in-transaction offenders | TC-SIG-048 | COVERED | BEHAVIORAL | Registered, filters for idle-in-txn state, excludes own PID |
| R054 | Database sizes | TC-SIG-048 | COVERED | BEHAVIORAL | Registered, passes linter |
| R055 | Largest relations | TC-SIG-048 | COVERED | BEHAVIORAL | Registered, passes linter |
| R056 | Temp I/O pressure | TC-SIG-048 | COVERED | BEHAVIORAL | Registered, passes linter, no secrets |
| R057 | Schema constraint inventory | TC-SIG-050 | COVERED | BEHAVIORAL | pg_constraints_v1: registered, linter pass, 24h cadence, schema filter, unnest/ordinality multi-column, deterministic order, constraint validity (is_validated from convalidated, #342) |
| R058 | Schema index definitions | TC-SIG-050 | COVERED | BEHAVIORAL | pg_indexes_v1: registered, linter pass, 24h cadence, schema filter, indexdef included, COALESCE tablespace |
| R059 | Column planner statistics | TC-SIG-051 | COVERED | BEHAVIORAL | pg_stats_v1: registered, linter pass, 24h cadence, schema filter, no data samples, n_distinct/correlation included |
| R060 | Column inventory with types | TC-SIG-052 | COVERED | BEHAVIORAL | pg_columns_v1: registered, linter pass, 24h cadence, schema filter, pg_attribute native, format_type, no default text, excludes system/dropped columns |
| R061 | Schema namespace inventory | TC-SIG-053 | COVERED | BEHAVIORAL | pg_schemas_v1: registered, linter pass, 24h cadence, schema filter, pg_namespace + pg_roles join, nspname/nspowner/is_default |
| R062 | View inventory | TC-SIG-054 | COVERED | BEHAVIORAL | pg_views_v1: registered, linter pass, 24h cadence, schema filter, inventory mode (no definition), pg_views source |
| R063 | View definitions | TC-SIG-054 | COVERED | BEHAVIORAL | pg_views_definitions_v1: registered, linter pass, includes pg_get_viewdef definition column, separate from inventory |
| R064 | Materialized view inventory | TC-SIG-055 | COVERED | BEHAVIORAL | pg_matviews_v1: registered, linter pass, 24h cadence, schema filter, inventory mode, ispopulated + hasindexes |
| R065 | Materialized view definitions | TC-SIG-055 | COVERED | BEHAVIORAL | pg_matviews_definitions_v1: registered, linter pass, includes definition column, separate from inventory |
| R066 | Partition topology | TC-SIG-056 | COVERED | BEHAVIORAL | pg_partitions_v1: registered, linter pass, 24h cadence, schema filter, pg_partitioned_table + pg_inherits + pg_get_partkeydef, parent/child with bounds |
| R067 | Trigger inventory | TC-SIG-057 | COVERED | BEHAVIORAL | pg_triggers_v1: registered, linter pass, 24h cadence, schema filter, tgtype bitmask, excludes internal triggers, pg_trigger + pg_proc |
| R068 | Trigger definitions | TC-SIG-057 | COVERED | BEHAVIORAL | pg_triggers_definitions_v1: registered, linter pass, includes pg_get_triggerdef, separate from inventory |
| R069 | Function/procedure inventory | TC-SIG-058 | COVERED | BEHAVIORAL | pg_functions_v1: registered, linter pass, 24h cadence, PG 11+, schema filter, prokind, no body, pg_proc + pg_language |
| R070 | Function body definitions | TC-SIG-058 | COVERED | BEHAVIORAL | pg_functions_definitions_v1: registered, linter pass, PG 11+, includes prosrc as body; HighSensitivity = true on the skip-path under R075 v2 (runs by default; opt-out via signals.high_sensitivity_collectors_enabled: false drops it as status=skipped, reason=config_disabled) |
| R071 | Sequence inventory and health | TC-SIG-059 | COVERED | BEHAVIORAL | pg_sequences_v1: registered, linter pass, 24h cadence, schema filter, pg_sequences view, 9 output columns |
| R072 | Collector execution status metadata | TC-SIG-060 | COVERED | BEHAVIORAL | collector_status.json: status types (success/partial/skipped/failed), reason categories, JSON shape, deterministic ordering, builder helpers, ZIP integration |
| R073 | Multi-target export correctness | TC-SIG-061 | COVERED | BEHAVIORAL | Target-filtered query runs/results, target-scoped collector_status.json, BuildStatusFromRuns, error classification, GetTargetName |
| R074 | Deterministic ordering | TC-SIG-050..061 | COVERED | BEHAVIORAL | All schema collectors use ORDER BY; collector_status sorted by ID; export ZIP in fixed file order |
| R075 | High-sensitivity collectors: default-on with opt-out (revised 2026-05) | TC-SIG-062 | COVERED | BEHAVIORAL | signals.high_sensitivity_collectors_enabled defaults to true (collect-everything default). When set to false, the opt-out branches per collector via QueryDef.SensitiveColumns: redact-path collectors (live pg_stat_activity: long_running_txns_v1, blocking_locks_v1, idle_in_txn_offenders_v1, wraparound_blockers_v1) keep running with their declared columns NULL-ed at execution time; skip-path collectors (whole-row-sensitive — DDL definitions, sampled-value stats, RLS policies, rewrite rules) are dropped and appear with status=skipped, reason=config_disabled (EA-R001). metadata.json.high_sensitivity_collectors_enabled records the effective state. |
| R076 | Strict startup config validation | TC-SIG-063 | COVERED | BEHAVIORAL | ValidateStrict returns (warnings, error); daemon aborts on hard errors (malformed env ints, duplicate target names, multiple credential sources, non-positive intervals, missing required fields); warnings logged and continue (sslmode=prefer in non-prod, very short intervals) |
| R077 | Atomic collection-cycle persistence | TC-SIG-064 | COVERED | BEHAVIORAL | Query runs, query results, and snapshot row persist in a single SQLite transaction via InsertCollectionAtomic; partial state not observable to readers or exports |
| R078 | Audit logging and export metadata | TC-SIG-065 | COVERED | BEHAVIORAL | audit_event slog records for startup config validation, per-target collection cycles, and export requests; export metadata.json carries signals_version, schema_version, generated_at, instance_id, target_name (when scoped), high_sensitivity_collectors_enabled, collector_status_schema_version; no secrets or query payloads in audit attributes |
| R079 | Operational metrics endpoint | TC-SIG-066 | COVERED | BEHAVIORAL | Optional Prometheus /metrics endpoint, off by default, gated by signals.metrics_enabled; emits 12 operational counters/gauges/histograms with bounded labels (target, status, reason, error_category); never exposes SQL text, query results, definitions, hostnames, dbnames, usernames, or paths; inherits the API's bearer auth |
| R080 | Per-collector export view | TC-SIG-067, TC-SIG-130..137 (tests/signals_per_collector_export_test.go) |
COVERED | BEHAVIORAL | Optional per-collector/<query_id>.json directory in the export ZIP, off by default, gated by signals.export_per_collector_files; canonical NDJSON layout remains authoritative. Strict regrouping of the already-resolved in-scope run set used by query_runs.ndjson / query_results.ndjson (issue #322) — never re-queried with a narrower selector, so --snapshot-id, --target-id, --all, --since/--until, and the R084 default (active targets only, orphans/disabled excluded) all match the canonical files. Grouped by (target_id, query_id), latest-run-wins, so multi-target exports keep target attribution (per-collector/<target_id>/<query_id>.json) instead of one target silently winning a query_id-only grouping. A missing/corrupt payload for a successful in-scope run fails the export (same guard as query_results.ndjson), never a success stub. |
| R081 | Version-aware query catalog | TC-SIG-068 | COVERED | BEHAVIORAL | Per-cycle discovery probe (server_version_num, major, extensions, database, current_user); per-major catalog files pg14..pg18 + pg19 placeholder; stable logical collector IDs; output column normalization across majors; PG 18 fixes pg_stat_io_v1 (read_bytes/write_bytes/extend_bytes vs op_bytes) and pg_stat_wal_v1 (wal_writes/wal_syncs vs wal_write/wal_sync); experimental fallback for PG 19 |
| R082 (Phase 1) | Optional target narrowing on POST /collect/now | TC-SIG-069..073 | COVERED | BEHAVIORAL | Empty body unchanged; valid subset accepted; unknown / disabled / empty-array targets → 400 with rejected list; cycle not triggered on any rejection. |
| R082 (Phase 2) | request_id + reason + audit correlation | TC-SIG-074..080 | COVERED | BEHAVIORAL | Optional request_id (^[A-Za-z0-9_-]{1,32}$) + reason (^[A-Za-z0-9_-]{1,64}$); ULID generated when absent. New audit events collect_now_requested / collect_now_rejected / collect_now_dropped. request_id propagated through collection_started / collection_completed. actor=local_operator invariant — control_plane reserved for Phase 3. R078 denylist still in force; no secrets / SQL / payload bodies leak into audit attributes. |
| R082 (Phase 3) | Mode flag + control_plane token + entitlement protocol | TC-SIG-081..092 | COVERED | BEHAVIORAL | Realised by R083 (v0.3.1). Phase 4 (collector profiles, entitlement metadata, status callbacks) remains future work and is out of scope for R083. |
| R083 | Mode B authentication + configuration | TC-SIG-081..092 (tests/signals_r083_managed_mode_test.go) |
COVERED | BEHAVIORAL | Implemented in v0.3.1. signals.mode: standalone | managed config flag (internal/config/config.go); separate control_plane_token_file/_env with constant-time dual-token comparison and actor derivation in the auth middleware (internal/api/server.go). control_plane actor only valid in mode=managed. Startup validation hard-errors for missing / duplicate / short token, conflicting _file+_env. Audit-event actor decoration in internal/safety/audit.go. |
| R084 | Default export scope = latest run per collector per active target | TC-SIG-093, TC-SIG-121 | PLANNED | BEHAVIORAL | Default signalsctl export (no flags) returns the latest run of each collector per active target — the row with the largest collected_at per (target_id, query_id) — plus the snapshots those runs belong to. Supersedes the interim latest-snapshot-per-target default, which dropped lower-cadence collectors after a high-cadence cycle (issue #5). Full history remains behind --all. |
| R085 | Explicit export selectors (--all, --snapshot-id, --since/--until, --target-id) |
TC-SIG-094, TC-SIG-095, TC-SIG-096 | PLANNED | BEHAVIORAL | Operator-driven selectors widen or narrow the R084 default. --all and --snapshot-id are mutually exclusive (FC-08). Existing --target-id and time-range params plumb through unchanged; this rule formalises them in the CLI. |
| R086 | Export metadata extensions: snapshot_count + run_scope + ingest_mode |
TC-SIG-097, TC-SIG-098, TC-SIG-122 | PLANNED | BEHAVIORAL | Required metadata.json fields: snapshot_count (integer ≥ 0, distinct snapshots in the ZIP), run_scope ("latest-per-collector" for the R084 default | "snapshot" for selector scopes), and ingest_mode ("analyze" | "history_only"). Operator-driven exports always emit "analyze"; "history_only" is reserved for R087 backlog replay. |
| R087 | Backlog replay shape (one ZIP per snapshot, ordered, mode-tagged) | TC-SIG-099 | PLANNED | BEHAVIORAL | DESIGN-ONLY in this PR slice. When implemented, a delivery-outage backlog of N pending snapshots replays as N independent ZIPs in collected_at ascending order; the first N−1 carry ingest_mode="history_only", the last carries ingest_mode="analyze". |
| R088 | Out of scope: delivery transport for backlog replay | n/a | PLANNED | n/a | Stakes the boundary. Transport (HTTP push, signed upload, mTLS), spool storage shape (column on snapshots table vs new pending_deliveries table), retry policy, and observability metrics are explicitly deferred to a future spec slice tied to the runtime portion of R082. |
| R089 | UpsertTarget idempotent — return real targets.id |
TC-SIG-100, TC-SIG-101, TC-SIG-102, TC-SIG-107 | COVERED | BEHAVIORAL | Always SELECT the canonical targets.id after the UPSERT; never trust LastInsertId(). Closes the v0.3.x drift where AUTOINCREMENT reserved-but-wasted ids leaked into snapshots.target_id. |
| R090 | Default scope filters orphaned target_id via JOIN |
TC-SIG-103, TC-SIG-104, TC-SIG-105, TC-SIG-106 | COVERED | BEHAVIORAL | GetLatestRunsPerCollector (the R084 default scope) JOINs targets to ignore orphans; the legacy GetLatestSnapshotsPerTarget / GetLatestSnapshotForTarget helpers carry the same JOIN for any path still using them. --all still surfaces orphans for forensics. |
| R091 | Min snapshot interval per logical target (default 60s) | TC-SIG-110..114, TC-SIG-116, TC-SIG-117 + integration smoke (tests/signals_min_snapshot_integration_test.go, //go:build integration) |
COVERED | BEHAVIORAL | min_snapshot_interval enforced in collectTarget before any PG work. First cycle for a target always runs (no prior snapshot). Skip path emits collection_skipped audit + writes zero rows. Build-tagged integration smoke exercises the full Run() orchestration against a real PG (SIGNALS_TEST_PG_DSN gated) so orchestration-layer regressions surface beyond the unit-level decision tests. |
| R092 | Explicit --force / force=true override |
TC-SIG-115 + integration smoke (tests/signals_min_snapshot_integration_test.go, //go:build integration) |
COVERED | BEHAVIORAL | Per-request override bypasses R091 only; forced=true recorded in audit. Does not bypass overlap protection (R032), per-query cadence (R015), or safety validation (R013/R018-R020). Integration smoke verifies the forced cycle produces a second snapshot when within the R091 window. |
| R093 | cluster_identity_v1 collector |
TC-CLUSTERID-01..05 (tests/signals_cluster_identity_test.go) |
COVERED | BEHAVIORAL | Collector registered in internal/pgqueries/catalog_diagnostics.go; emits one row per cycle with inet_server_addr, inet_server_port, is_in_recovery, cluster_name (NULLIF empty), server_timezone, last_wal_receive_lsn, last_wal_replay_lsn, postmaster_start_time. Composite key (inet_server_addr, inet_server_port, postmaster_start_time) disambiguates clusters in v1. system_identifier is out of scope — see spec § Out of scope. Cadence 6h, RetentionLong. |
| R094 | Export metadata.json carries target_identity block; per-snapshot identity in snapshots.ndjson |
TC-SIG-118..123 (tests/signals_export_target_identity_test.go) |
COVERED | BEHAVIORAL | Single-target exports embed {host, port, dbname, username} at the top level of metadata.json via internal/db/db.go::GetTargetIdentity and internal/export/export.go::writeMetadata. Multi-target exports additionally embed target_identity per row in snapshots.ndjson via writeSnapshots (per-target cache to avoid N+1 lookups). Unscoped exports omit the top-level metadata block. Orphan snapshots omit the per-row block when the target_id does not resolve (R090 case). Connection identity only — never password / secret-ref / sslmode (INV-SIGNALS-07). |
| R095 | signalsctl doctor pre-flight verification |
TC-DOC-01..12 (internal/doctor/doctor_test.go) + CLI wiring (cmd/signalsctl/doctor.go) |
COVERED | BEHAVIORAL | CLI subcommand runs six independent operator-facing checks (C1 config_valid, C2 store_writable, C3 target_reachable, C4 role_safe, C5 collector_prerequisites, C6 snapshot_freshness). Reports union of findings; exit 0 = all OK, 1 = any FAIL, 2 = usage error. JSON output via --json; check filter via --check. Dependency rules: C4 auto-adds C3; C5 auto-adds C4+C3; C6 has no dependencies (reads daemon SQLite store independently). C5 reuses pgqueries.GatedIDsByReason to classify each registered collector as available / extension_missing / version_unsupported / config_disabled. C6 reads db.GetLatestSnapshotTimeByTargetName and compares against signals.poll_interval. Read-only and credential-safe per INV-DOC-01..03. Implementation: internal/doctor/ + cmd/signalsctl/doctor.go. Spec: specifications/doctor.md. |
| R096 | signalsctl connect test classified connection diagnostic |
TC-CONN-01..10 (internal/conntest/conntest_test.go) + CLI wiring (cmd/signalsctl/connect.go) |
COVERED | BEHAVIORAL | Single-target / ad-hoc / multi-target connection diagnostic with classified failure categories (ok, dns, tcp, tls, auth, startup, role, password_resolve, config). Reuses collector.BuildSafeDSN (extracted from internal/doctor in this slice), collector.ResolvePassword, collector.ValidateRoleSafety, collector.RedactError, collector.RedactDSN. Exit 0 / 1 / 2 mirrors R095 semantics via shared usageError / failError types. INV-CONN-01..04 hold (read-only, no credential leakage, deterministic classification, config-declared output order). Implementation: internal/conntest/ (classify + run aggregator), cmd/signalsctl/connect.go (thin CLI shell). Spec: specifications/connect-test.md. |
| R097 | Circuit breaker — per-target backoff + operator pause | TC-CIRC-01..08 + auto-transition tests (internal/circuit/circuit_test.go) |
COVERED | BEHAVIORAL | Per-target in-memory state machine in internal/circuit/ (closed / open / paused). Auto: fail_threshold consecutive collectTarget errors → open; open_cooldown → back to closed. Manual: signalsctl collect pause/resume + POST /collect/pause//collect/resume set/clear paused. Manual priority over auto (INV-CIRC-02). State in memory only — restart resets all to closed (INV-CIRC-01); audit log preserves the pause/resume trail. Gating runs in collectTarget ahead of R091, so skipped cycle writes zero rows (INV-CIRC-03) with reason_category circuit_open / circuit_paused. /metrics exposes signals_circuit_state{target,state} gauge with one row per (target, state). Daemon constructs circuit.NewManager from signals.circuit config + threads it via collector.WithCircuitManager. Spec: specifications/circuit-breaker.md. |
| R098 | Per-target sensitivity profiles | TC-SENS-01..05 (tests/signals_sensitivity_profiles_test.go) |
COVERED | BEHAVIORAL | config.TargetConfig.Collectors adds optional profile: default | restricted | custom with include / exclude lists. Layered AFTER R081 version/extension gates and R075 daemon-wide gate (INV-SENS-01: profiles never widen eligibility). restricted drops every HighSensitivity=true collector for this target only. custom honours include allowlist + exclude deny-list (ValidateStrict rejects same-ID overlap, FC-20). Filter logic in pgqueries.FilterParams.{ProfileRestricted, IncludeOnly, Exclude}; collector wiring in collector.applyTargetProfile. Per-target drops surface in collector_status.json under reason=config_disabled (EA-R001 channel). Spec: specifications/sensitivity-profiles.md. |
| R099 | Per-class retention | tests/signals_retention_per_class_test.go + existing snapshot/runs cleanup tests + internal/db/retention_atomic_test.go (FC-23 atomicity) |
COVERED | BEHAVIORAL | signals.retention structured block adds per-class day counts (short/medium/long). cleanup() prunes query_runs per class via db.DeleteQueryRunsOlderThanByClass joining query_catalog.retention_class. Snapshot rows pruned by max(class_days) envelope so a snapshot stays alive while any class still needs it. Backward-compat: signals.retention_days (flat) and signals.retention (structured) are mutually exclusive (FC-21); flat alone behaves identically to today. Atomic pruning (FC-23, #327): both retention helpers (DeleteQueryRunsOlderThan, DeleteQueryRunsOlderThanByClass) wrap the query_results delete + query_runs delete in ONE SQLite transaction; a failure after the payload delete rolls both back, so retention is a safe producer of INV-SNAP-STATUS-PAYLOAD and cannot re-create the #312 orphaned-success failure. Spec section in features/signals/specification.md § Per-class retention (Atomic pruning). |
| R100 | Configuration reload (SIGHUP + POST /reload) | tests/signals_config_reload_test.go (5 tests + race) |
COVERED | BEHAVIORAL | Daemon supports in-place reload of the runtime-mutable subset of config. Triggers: SIGHUP signal handler in cmd/signals/main.go + POST /reload endpoint (internal/api/reload.go). Both run config.Load + config.ValidateStrict BEFORE applying — rejected reloads emit config_reload_rejected audit and leave running state untouched. On success: collector.Reload(newTargets) swaps the target list under runtimeMu, closes pools for removed and connection-modified targets, emits per-target add/remove/modify audit events. v1 scope = targets list only; poll_interval, retention, circuit thresholds remain set-at-construction (documented as future scope in spec § Configuration reload). |
| R100.1 | Connection-identity pool invalidation on reload | tests/signals_config_reload_test.go (TestReload_PoolInvalidatedPerConnectionField table [Host, Port, DBName, User, SSLMode, SSLRootCertFile, SSLCert, SSLKey, SSLKeyPassphraseFile, AuthMethod, Region, AzureClientID, GCPImpersonateServiceAccount, SecretRef, SecretJSONKey, PasswordFile, PasswordEnv, PgpassFile, MaxCacheTTL], TestReload_ProfileOnlyChangeKeepsPool, TestReload_NewConnectionResolvesWithNewConfig, TestConnIdentity_CoversEveryConnectionField) |
COVERED | BEHAVIORAL | Fixes #328. sameConnection compared only a subset (host/port/db/user/sslmode + 3 password-source fields) and silently omitted the TLS/credential/cache fields, so a reload that changed only AuthMethod, SecretRef, cloud-identity selectors, TLS CA/mTLS files, or MaxCacheTTL kept the stale pool — and the pool's BeforeConnect closure captured the OLD TargetConfig, so even future connections resolved the old credential. Fix: sameConnection now compares a derived connIdentity struct built from the exhaustive pool-affecting field set; TestConnIdentity_CoversEveryConnectionField reflects over TargetConfig so a new connection/credential field added to config forces a test-time update rather than a silent omission. Profile-only (collectors) and Enabled-only changes are excluded from the identity and do NOT drop the pool. internal/collector/collector.go (connIdentity, sameConnection, reload path). |
| R105 | Index bloat estimate (index_bloat_estimate_v1) |
TC-IDXBLOAT-01..10 (tests/signals_index_bloat_estimate_test.go) |
COVERED | BEHAVIORAL | Sibling of R104 for indexes. Registered in internal/pgqueries/catalog_index_bloat.go. One row per non-system relkind IN ('i','I') index. Formula: expected ≈ CEIL(reltuples × (Σ key_avg_width + 8 + 4) / GREATEST(block_size - 24, 1)) × block_size. Constants differ from R104 (index tuple header 8, item pointer 4, page header 24) to match the index-tuple layout. Width sum is bounded by pg_index.indnkeyatts so INCLUDE columns are excluded. Expression-key indexes emit stats_missing = TRUE (avg_width lookup via pg_index.indkey → pg_attribute.attname → pg_stats can't resolve expression columns). Partitioned-index parents (relkind = 'I') surface with actual_size_bytes = 0. System schemas excluded (INV-SIGNALS-12). CTE structure: page, idx_widths, base, estimated. No extension dependency. Cadence6h, RetentionMedium, Timeout=30s. Spec: specifications/collectors/index_bloat_estimate_v1.md. |
| R104 | Table bloat estimate (bloat_estimate_v1) |
TC-BLOAT-01..09 (tests/signals_bloat_estimate_test.go) |
COVERED | BEHAVIORAL | Statistical bloat estimate registered in internal/pgqueries/catalog_bloat.go. One row per non-system relkind IN ('r','m','p') relation with actual vs expected size, floored bloat_bytes, and bloat_ratio ∈ [0.000, 1.000] (or NULL when pg_stats rows are missing). Formula: expected ≈ CEIL(reltuples × (23 + 4 + Σ avg_width + 8) / GREATEST(block_size - 24, 1)) × block_size, page size from current_setting('block_size') so non-default compile sizes (4K/16K) work. Adjacent columns surface n_live_tup / n_dead_tup / last_autovacuum from pg_stat_user_tables for cross-corroboration. System schemas excluded (INV-SIGNALS-12). CTE structure: page, widths, base, estimated. No extension dependency — runs on managed PG (RDS / Aurora / Cloud SQL / AlloyDB / Azure Flex) where pgstattuple is unavailable. Cadence6h, RetentionMedium, Timeout=30s. Index-bloat sibling intentionally deferred to a follow-up issue. Spec: specifications/collectors/bloat_estimate_v1.md. |
| R103 | Index hygiene summary (index_health_summary_v1) |
TC-IDXHEALTH-01..08 (tests/signals_index_health_summary_test.go) |
COVERED | BEHAVIORAL | Single collector registered in internal/pgqueries/catalog_index_health.go. One row per non-system index with derived health_findings text-array drawn from {unused, large_unused, invalid, not_ready, redundant, duplicate}. SQL uses CTEs (idx_cols, idx_meta) plus two LATERAL self-joins to compute duplicate_of (lower-OID twin with identical column_set on the same table) and redundant_with (strict left-prefix of a larger index on the same table). System schemas excluded (INV-SIGNALS-12). column_set ordering matches index definition (uses pg_index.indkey ordered by ordinality, bounded by indnkeyatts so INCLUDE columns are skipped). Expression indexes emit NULL column_set and never match duplicate/redundant. Cadence6h, RetentionMedium, Timeout=30s, ResultRowset, no MinPGVersion. Spec: specifications/collectors/index_health_summary_v1.md. |
| R102 | In-flight operation visibility (pg_stat_progress_* family) |
TC-PROG-01..07 (tests/signals_pg_stat_progress_test.go) |
COVERED | BEHAVIORAL | Six collectors registered in internal/pgqueries/catalog_progress.go (vacuum / analyze / create_index / cluster / basebackup / copy). Category progress, Cadence5m, RetentionShort, ResultRowset, MinPGVersion: 14, sensitivity low. Empty rowset on quiet clusters is the success state. Column drift handled via RegisterOverride(17, ...) and RegisterOverride(18, ...) for pg_stat_progress_vacuum (PG 17 byte-denominated dead-tuple columns + num_dead_item_ids + indexes_total + indexes_processed; PG 18 added delay_time) and pg_stat_progress_copy (PG 17 added tuples_skipped). Canonical SQL emits the union with NULL stubs; per-major overrides flip which columns are populated. Drift allowlist in tests/signals_catalog_drift_test.go covers the SQL-level NULL-stub flip with citation. Spec: specifications/collectors/pg_stat_progress_family_v1.md. |
| R101 | Logical replication slot health (pg_stat_replication_slots_v1) |
TC-RSLOTS-01..05 (tests/signals_pg_stat_replication_slots_test.go) |
COVERED | BEHAVIORAL | Collector registered in internal/pgqueries/catalog_survival.go against pg_stat_replication_slots (PG 14+). Emits one row per logical slot with explicit column projection (no SELECT *): slot_name, spill_txns, spill_count, spill_bytes, stream_txns, stream_count, stream_bytes, total_txns, total_bytes, stats_reset. Category replication, Cadence5m, RetentionShort, MinPGVersion: 14. Empty rowset on instances with no logical slots is the success state (INV). On PG < 14 the collector is excluded by R081 dispatch and surfaces in collector_status.json with reason=version_unsupported via EA-R001. Spec: specifications/collectors/pg_stat_replication_slots_v1.md. |
| EA-R001 | Canonical channel: gated collectors → collector_status.json |
TC-EXTABS-01..05 | COVERED | BEHAVIORAL | Cross-cutter specifications/extension-absent-emission.md (status: ACTIVE). Gating implemented in internal/pgqueries/registry.go::GatedIDsByReason (extension_missing / version_unsupported / config_disabled); internal/collector/collector.go writes the QueryRun rows with status=skipped. collector_status.json schema: internal/collector/status.go. Existing tests: tests/signals_collector_status_test.go, tests/signals_post_031_test.go (H-002). No rows written to query_results.ndjson for gated collectors. |
| EA-R002 | collector_status.json present in every export ZIP |
TC-SIG-009 (TestExportZIPContainsRequiredFiles) |
COVERED | BEHAVIORAL | internal/export/export.go::Builder.WriteTo writes the file unconditionally (INV-SIGNALS-11). Export test asserts presence in the required-files list (tests/signals_export_test.go). |
| EA-R004 | Analyzer surfaces per-collector reason via EvidenceCompleteness |
analyzer-side test | COVERED | BEHAVIORAL | Implemented in the Elevarq Analyzer repo (~/Projects/elevarq/arq/internal/analyzer/evidence/completeness.go). Detectors distinguish ExtensionUnavailable / VersionUnsupported / ConfigDisabled / CollectorFailed via the reason field exposed on the Completeness model. |
| R106 | pg_stat_statements self-filtering + fixed application_name |
TC-SIG-118, TC-SIG-119, TC-SIG-120 (tests/signals_conn_test.go, tests/signals_pg_stat_statements_filter_test.go) |
COVERED | BEHAVIORAL | Connection application_name sourced from collector.AppName (single constant); applied via pgx.ConnConfig.RuntimeParams in BuildConnConfig. pg_stat_statements_v1 SQL in internal/pgqueries/catalog.go joins pg_database to scope rows to current_database() (pg_stat_statements.dbid = (SELECT oid FROM pg_database WHERE datname = current_database())) and applies a NOT EXISTS correlated subquery against pg_stat_activity to suppress rows attributable to sessions where application_name = 'signals' (matching on userid ↔ usesysid and dbid ↔ datid). SELECT * projection preserved for R037 dynamic-column capture; ID, category, retention class, and RequiresExtension unchanged. INV-SIGNALS-16/17/18. |
| R110 | Export consistency + per-call local scope concurrency safety | TC-SIG-138, TC-SIG-139 (tests/signals_export_consistency_test.go) |
COVERED | BEHAVIORAL | Exports take a shared read lock serialised against destructive retention writes (existing). Request-specific scope (resolved snapshots, resolved run set, snapshot-ID membership map, run-scope label) is local and immutable for one WriteTo call — carried in a per-call value threaded through every writer, never on the shared Builder (issue #323). Two concurrent WriteTo calls on the production shared Builder with different selectors (--snapshot-id/--target-id vs --all/default) each contain only their own scope; holds under go test -race. The only remaining Builder mutable state is set-once construction-time config, read-only for the daemon lifetime. |
| R109 | Disabled/removed targets excluded from default export + /status; targets.enabled reconciled on startup/reload |
TC-SIG-125 | PLANNED | BEHAVIORAL | GetLatestRunsPerCollector and the per-target snapshot helpers JOIN targets with t.enabled = 1; /status lists only enabled targets. ReconcileEnabledTargets(enabledNames) (called on startup + reload) soft-disables targets disabled-in-config or removed-from-config and re-enables present ones, without deleting snapshots (--all still surfaces them). Closes the drift where lazy UpsertTarget left a disabled/removed target at enabled=1. INV-SIGNALS-20, INV-SIGNALS-14. |
| R108 | Budget-exhaustion completeness: remaining due collectors recorded skipped/budget_exhausted; cycle partial; persistence + savepoint recovery survive elapsed budget |
TC-SIG-124 (helpers: internal/collector/budget_exhaustion_test.go; live production loop: internal/collector/budget_exhaustion_live_test.go, //go:build integration, SIGNALS_TEST_PG_DSN-gated, runs under -race) |
COVERED | BEHAVIORAL + INTEGRATION | On per-cycle budget exhaustion the collection loop records a skipped run with reason=budget_exhausted for every remaining due collector (both budget break points), so query_runs rows == due collectors (INV-SIGNALS-19). The read-tx commit + SQLite persistence use a fresh context so an over-budget cycle still persists its complete inventory. Savepoint recovery (ROLLBACK TO SAVEPOINT / RELEASE) after a collector's own query fails on the exhausted budget also runs under a bounded fresh context, not the elapsed budget context — otherwise the recovery is rejected on the expired ctx, the cycle aborts before appending the remaining budget_exhausted runs, and the whole partial cycle is discarded (#329, the production-path regression lock). Cycle status is partial when any budget_exhausted skip occurred. Pure helpers budgetSkippedRuns + cycleStatus unit-tested; the live integration test forces the target budget to expire during a collector query and asserts M due collectors → M persisted query_runs (the attempted one failed/timeout, the rest skipped/budget_exhausted), atomic snapshot + payloads, and a partial cycle. #329 closes the #8 completeness gap on the production path. |
| R107 | Collector freshness metadata (collected_at + cadence + freshness) |
TC-SIG-123 | PLANNED | BEHAVIORAL | collector_status.json entries carry per-collector collected_at, expected cadence, and a freshness classification (fresh ≤ 2× cadence, stale > 2× cadence, never_run = eligible but no run in scope). Default export enumerates eligible-but-never-run collectors so consumers distinguish current / stale / missing coverage. Pairs with the latest-run-per-collector default scope (R084) and the Analyzer ingest side (Elevarq/Arq#907). |
| R111 | Diagnostic DSN values libpq-quoted — no parameter injection via password/field values | TC-SIG-126 (internal/collector/secrets_test.go) |
COVERED | BEHAVIORAL | collector.BuildSafeDSN quotes every string-valued field (wrap in '…', escape \ and '); hostile values (embedded sslmode=disable host=evil, quotes, backslashes, whitespace) round-trip as exact literals through pgx.ParseConfig without re-targeting the connection or downgrading TLS posture. Production path BuildConnConfig unchanged (already net/url-escaped). INV-SIGNALS-21. Spec: features/signals/specification.md R111; issue #69. |
| R112 | Auth lockout never denies a valid bearer token — limiter gates only the invalid path | TC-SIG-127 (internal/api/token_auth_lockout_test.go) |
COVERED | BEHAVIORAL | tokenAuthMiddleware validates the token (constant-time vs api.token / control_plane_token) before consulting the per-IP tokenRateLimiter; a valid token from a locked-out IP authenticates and clears the IP's failure counter, while invalid/missing tokens over the threshold still get 429 (brute-force throttle preserved). Fixes the shared-IP (NAT / proxy / co-located pod) operator-lockout DoS. INV-SIGNALS-22. Spec: features/signals/specification.md R112; issue #68. |
| R114 | TimescaleDB collector family (12 members, category timescaledb): detection + capability flags, hypertables, dimensions, chunks (≤5000) + summary, approximate sizes, compression settings/stats, continuous aggregates, jobs/job_stats/job_errors (≤1000) |
TC-TSDB-01..11, TC-TSDB-13, TC-TSDB-15 (tests/signals_timescaledb_test.go); live scenarios in tests/signals_timescaledb_integration_test.go (//go:build integration, SIGNALS_TEST_TSDB_DSN-gated) |
COVERED | BEHAVIORAL + INTEGRATION | Implemented in internal/pgqueries/catalog_timescaledb.go (#73). Documented PUBLIC-readable timescaledb_information views + hypertable_approximate_detailed_size() / hypertable_compression_stats() only; no internal catalogs, no exact size functions. Dynamic columns (R037) absorb cross-version view drift. Redact-path sensitivity on view_definition / err_message (R075). INV-SIGNALS-24. Spec (ACTIVE): specifications/collectors/timescaledb_family_v1.md; design: docs/timescaledb-collectors-design.md; permissions: docs/postgres-role.md § TimescaleDB targets. |
| R115 | Extension-version gating: discovery captures extversion; QueryDef.RequiresExtensionMinVersion gates under version_unsupported (extension_missing keeps precedence); SQLSTATE 42P01/42883 → structured object_missing reason; fail-open on unknown/unparsable versions |
TC-TSDB-12, TC-TSDB-14 (tests/signals_extension_version_gate_test.go) |
COVERED | BEHAVIORAL | Implemented in internal/pgqueries/{discovery,types,registry}.go + internal/collector/status.go classifier; wired through collector.go and doctor C5. First consumer: R114 (RequiresExtensionMinVersion: "2.14", detection collector exempt). |
| R113 | Optional API TLS at the daemon (api.tls_cert_file / tls_key_file); all-or-nothing; min TLS 1.2 |
TC-SIG-128 (tests/signals_api_tls_config_test.go), TC-SIG-129 (internal/api/server_tls_test.go) |
COVERED | BEHAVIORAL | APIConfig.TLSCertFile/TLSKeyFile (+ SIGNALS_API_TLS_CERT_FILE/_KEY_FILE env overrides); validation hard-errors when exactly one is set (TC-SIG-128). Server.Start calls ServeTLS with MinVersion: tls.VersionTLS12 when both are set, plain Serve otherwise; HTTPS smoke with a self-signed cert proves the listener negotiates TLS and serves /health, plain path unchanged (TC-SIG-129). Closes the cleartext-bearer-token exposure on the Helm 0.0.0.0 bind. Helm: NetworkPolicy 0.0.0.0/0 placeholder fails render when the policy is enabled; NOTES warns on exposed-without-TLS-or-NetworkPolicy. INV-SIGNALS-23. Spec: features/signals/specification.md R113; issue #67. |
| R127 | Scheduled per-database file export (#350): when export_on_collect + export_dest are set, the post-cycle hook writes the latest snapshot for EACH active target as its own export ZIP (Options{TargetID}) into export_dest — one file per database, never a combined multi-target archive (which a directory-sweeping consumer reads as a single database) |
TC-SIG-130 (TestScheduledExporter_ExportLatest_OneFilePerTarget), TC-SIG-131 (TestScheduledExporter_ExportLatest_SingleTargetIsScoped), TC-SIG-132 (TestScheduledExporter_ExportLatest_TargetListErrorWritesNothing), TC-SIG-133 (TestScheduledExporter_ExportLatest_WriteFailureLeavesNoFile) + TestScheduledExporter_exportFilename (internal/export/scheduled_test.go) |
COVERED | BEHAVIORAL | ScheduledExporter.ExportLatest enumerates targets via Builder.LatestTargetIDs (GetLatestSnapshotsPerTarget) and writes one target-scoped ZIP each; atomic temp+rename (INV-SIGNALS-R127-ATOMIC — no partial archive); flat unique filename <instance>-t<targetID>-<ns-UTC>.zip so same-cycle targets never collide (INV-SIGNALS-R127-UNIQUE); a per-target failure is returned to the collector post-cycle hook (cmd/signals/main.go SetAfterCycle), which logs and continues so collection is never disrupted. Fixes the multi-database demo collapsing to one database in the Analyzer (#368; timeseries-demo#322). Spec: features/signals/specification.md R127. |
Spec: features/signals/credential-provider-aws-rds-iam.md (ACTIVE), deriving from credential-providers.md (keystone, #93). Provider dispatch + token cache in internal/collector/credprovider.go; AWS minter/region/guidance in internal/collector/credprovider_aws.go; auth_method/region config + validation in internal/config/config.go. Unit tests internal/collector/credprovider_test.go + internal/config/authmethod_test.go; env-gated live smoke internal/collector/credprovider_live_test.go (//go:build integration, SIGNALS_INTEGRATION_LIVE=1).
| Rule ID | Rule Summary | Test ID(s) | Coverage Status | Evidence Type | Notes |
|---|---|---|---|---|---|
| SIGNALS-AUTH-AWS-AC-001 | Token minted, applied as password, ExpiresAt ~15m; minter inputs (endpoint/region/db_user) correct |
TestResolveAWSMintsTokenAsPassword |
COVERED | BEHAVIORAL | Fake minter; no real AWS call (NFR003). |
| SIGNALS-AUTH-AWS-AC-002 | Cache reuse within 3m skew; re-mint past ~12m; per-target isolation (distinct cache key) | TestResolveAWSCacheRefreshAndIsolation |
COVERED | BEHAVIORAL | Fake clock + fake minter; cache key = ConnIdentity() + method. |
| SIGNALS-AUTH-AWS-AC-003 | aws_rds_iam + any password source → hard startup error naming the target |
TestValidateRejectsAWSWithPasswordSource |
COVERED | BEHAVIORAL | FC-AWS-003; covers password_file/password_env/pgpass_file. |
| SIGNALS-AUTH-AWS-AC-004 | aws_rds_iam requires verify-full in every env; rejects weaker; accepts verify-full |
TestValidateRejectsAWSWithoutVerifyFull, TestValidateAcceptsAWSVerifyFull |
COVERED | BEHAVIORAL | FC-AWS-004 / INV003. |
| SIGNALS-AUTH-AWS-AC-005 | Mint failure → redacted, method-attributable error; no token returned/logged | TestResolveAWSMintFailure |
COVERED | BEHAVIORAL | FC-AWS-001 + INV002. |
| SIGNALS-AUTH-AWS-AC-006 | Region unresolved → connect-time target-scoped error, minter never called; missing region at startup → warning only (fail-soft) | TestResolveAWSRegionUnresolved, TestValidateWarnsButDoesNotFailOnMissingAWSRegion |
COVERED | BEHAVIORAL | FC-AWS-005; startup warning vs connect-time failure split. |
| SIGNALS-AUTH-AWS-AC-007 | Successful resolution logs metadata (auth_method/region/db_user/expires_at), never the token | TestResolveAWSLogsMetadataNotToken |
COVERED | BEHAVIORAL | INV002/INV007; log-capture asserts token absent. |
| SIGNALS-AUTH-AWS-AC-008 | Live passwordless connect + read-only snapshot; token re-minted across reconnect crossing the skew | TestLive_AWSRDSIAMPasswordlessConnectAndRemint |
COVERED | INTEGRATION | //go:build integration + SIGNALS_INTEGRATION_LIVE=1; real minter, injectable clock forces real re-mint. Not in default CI. |
| SIGNALS-AUTH-AWS-AC-009 | Grant/policy guidance names GRANT rds_iam TO "<user>" + rds-db:connect, no secret material |
TestAWSGrantGuidance |
COVERED | BEHAVIORAL | AWSGrantGuidance. |
| keystone FC001 / NFR003 | Unimplemented auth_method → hard error naming bad method + supported set; empty defaults to password |
TestValidateRejectsUnsupportedAuthMethod, TestEffectiveAuthMethodDefaultsToPassword |
COVERED | BEHAVIORAL | SupportedAuthMethods gate; back-compat default. |
| keystone NFR003 (back-compat) | Empty auth_method resolves via existing password source, no expiry, minter never called |
TestResolvePasswordPathUnchanged |
COVERED | BEHAVIORAL | Password path unchanged. |
Spec: features/signals/credential-provider-azure-entra.md (ACTIVE), deriving from credential-providers.md (keystone, #93). Reuses the shared dispatch + token cache in internal/collector/credprovider.go; Azure minter/client-id/guidance in internal/collector/credprovider_azure.go; auth_method/azure_client_id config + validation in internal/config/config.go. Unit tests internal/collector/credprovider_azure_test.go + internal/config/authmethod_azure_test.go; env-gated live smoke internal/collector/credprovider_azure_live_test.go (//go:build integration, SIGNALS_INTEGRATION_LIVE=1).
| Rule ID | Rule Summary | Test ID(s) | Coverage Status | Evidence Type | Notes |
|---|---|---|---|---|---|
| SIGNALS-AUTH-AZURE-AC-001 | Token acquired, applied as password, ExpiresAt = token expiry; minter called for the fixed scope |
TestResolveAzureMintsTokenAsPassword |
COVERED | BEHAVIORAL | Fake minter; no real Azure call (NFR003). Scope hard-pinned (INV004). |
| SIGNALS-AUTH-AZURE-AC-002 | Cache reuse within 5m skew; re-acquire past ~70m of a 75m token; per-target isolation (distinct cache key) | TestResolveAzureCacheRefreshAndIsolation |
COVERED | BEHAVIORAL | Fake clock + fake minter; cache key = ConnIdentity() + method (INV003/NFR001). |
| SIGNALS-AUTH-AZURE-AC-003 | azure_entra + any password source → hard startup error naming the target |
TestValidateRejectsAzureWithPasswordSource |
COVERED | BEHAVIORAL | FC-AZURE-003; covers password_file/password_env/pgpass_file. |
| SIGNALS-AUTH-AZURE-AC-004 | azure_entra requires verify-full in every env; rejects weaker; accepts verify-full; missing azure_client_id does not warn |
TestValidateRejectsAzureWithoutVerifyFull, TestValidateAcceptsAzureVerifyFull, TestValidateAcceptsAzureWithClientID |
COVERED | BEHAVIORAL | FC-AZURE-004 / INV003. |
| SIGNALS-AUTH-AZURE-AC-005 | Mint failure → redacted, method-attributable error; no token returned/logged | TestResolveAzureMintFailure |
COVERED | BEHAVIORAL | FC-AZURE-001 + INV002. |
| SIGNALS-AUTH-AZURE-AC-006 | Identity failure → actionable error naming azure_client_id disambiguation; leaves no cached entry; other targets keep resolving |
TestResolveAzureIdentityErrorIsActionableAndIsolated |
COVERED | BEHAVIORAL | FC-AZURE-005; target-scoped, non-fatal to siblings. |
| SIGNALS-AUTH-AZURE-AC-007 | Successful resolution logs metadata (auth_method/scope/db_user), never the token | TestResolveAzureLogsMetadataNotToken |
COVERED | BEHAVIORAL | INV002/INV007; log-capture asserts token absent. |
| SIGNALS-AUTH-AZURE-AC-008 | Live passwordless connect + read-only snapshot; token re-acquired across reconnect crossing the skew | TestLive_AzureEntraPasswordlessConnectAndReacquire |
COVERED | INTEGRATION | //go:build integration + SIGNALS_INTEGRATION_LIVE=1; real minter, injectable clock forces real re-acquire. Not in default CI. |
| SIGNALS-AUTH-AZURE-AC-009 | Guidance names pgaadauth_create_principal('<user>', ...) + display-name match note, no secret material |
TestAzureEntraGuidance |
COVERED | BEHAVIORAL | AzureEntraGuidance. |
| (design) client-id resolution | azure_client_id config wins; else AZURE_CLIENT_ID env; else empty (chain default) |
TestResolveAzureClientIDResolution |
COVERED | BEHAVIORAL | Confirmed user-assigned MI disambiguation. |
Spec: features/signals/credential-provider-gcp-cloudsql-iam.md (ACTIVE), deriving from credential-providers.md (keystone, #93). Reuses the shared dispatch + token cache in internal/collector/credprovider.go; GCP minter/impersonation/guidance in internal/collector/credprovider_gcp.go; auth_method/gcp_impersonate_service_account config + validation in internal/config/config.go. Connection path: direct libpq + verify-full (token-as-password), reusing the existing BeforeConnect seam — not the Cloud SQL Go Connector. Unit tests internal/collector/credprovider_gcp_test.go + internal/config/authmethod_gcp_test.go; env-gated live smoke internal/collector/credprovider_gcp_live_test.go (//go:build integration, SIGNALS_INTEGRATION_LIVE=1).
| Rule ID | Rule Summary | Test ID(s) | Coverage Status | Evidence Type | Notes |
|---|---|---|---|---|---|
| SIGNALS-AUTH-GCP-AC-001 | Token acquired, applied as password, ExpiresAt = token expiry; minter called for the fixed scope |
TestResolveGCPMintsTokenAsPassword, TestEffectiveAuthMethodGCPCloudSQLIAM |
COVERED | BEHAVIORAL | Fake minter; no real GCP call (NFR003). Scope …/auth/sqlservice.login hard-pinned (INV004). |
| SIGNALS-AUTH-GCP-AC-002 | Cache reuse within 5m skew; re-acquire past ~55m of a 60m token; per-target isolation (distinct cache key) | TestResolveGCPCacheRefreshAndIsolation |
COVERED | BEHAVIORAL | Fake clock + fake minter; cache key = ConnIdentity() + method (INV003/NFR001). |
| SIGNALS-AUTH-GCP-AC-003 | gcp_cloudsql_iam + any password source → hard startup error naming the target |
TestValidateRejectsGCPWithPasswordSource |
COVERED | BEHAVIORAL | FC-GCP-003; covers password_file/password_env/pgpass_file. |
| SIGNALS-AUTH-GCP-AC-004 | gcp_cloudsql_iam requires verify-full in every env; rejects weaker; accepts verify-full; missing impersonation SA does not warn |
TestValidateRejectsGCPWithoutVerifyFull, TestValidateAcceptsGCPVerifyFull, TestValidateAcceptsGCPWithImpersonation |
COVERED | BEHAVIORAL | FC-GCP-004 / INV003; direct-libpq path. |
| SIGNALS-AUTH-GCP-AC-005 | Mint failure → redacted, method-attributable error; no token returned/logged | TestResolveGCPMintFailure |
COVERED | BEHAVIORAL | FC-GCP-001 + INV002. |
| SIGNALS-AUTH-GCP-AC-006 | Identity failure → actionable error naming gcp_impersonate_service_account/ADC remediation; leaves no cached entry; other targets keep resolving |
TestResolveGCPIdentityErrorIsActionableAndIsolated |
COVERED | BEHAVIORAL | FC-GCP-005; target-scoped, non-fatal to siblings. |
| SIGNALS-AUTH-GCP-AC-007 | Successful resolution logs metadata (auth_method/scope/db_user), never the token | TestResolveGCPLogsMetadataNotToken |
COVERED | BEHAVIORAL | INV002/INV007; log-capture asserts token absent. |
| SIGNALS-AUTH-GCP-AC-008 | Live passwordless connect + read-only snapshot; token re-acquired across reconnect crossing the skew | TestLive_GCPCloudSQLIAMPasswordlessConnectAndReacquire |
COVERED | INTEGRATION | //go:build integration + SIGNALS_INTEGRATION_LIVE=1; real minter, injectable clock forces real re-acquire. Not in default CI. |
| SIGNALS-AUTH-GCP-AC-009 | Guidance names gcloud sql users create + the target role, no secret material |
TestGCPCloudSQLGuidance |
COVERED | BEHAVIORAL | GCPCloudSQLGuidance. |
| (design) impersonation resolution | gcp_impersonate_service_account handed to the minter when set; else empty (ambient ADC) |
TestResolveGCPImpersonationResolution |
COVERED | BEHAVIORAL | Confirmed per-target service-account impersonation. |
Spec: features/signals/credential-provider-secret-store.md (ACTIVE), deriving from credential-providers.md (keystone, #93). Backend inference (SecretBackend, ParsedSecretRef, InferSecretBackend, AWS region-from-ARN) in internal/config/secretref.go; auth_method/secret_ref/secret_json_key/max_cache_ttl config + validation in internal/config/config.go. Shared dispatch + per-target cache in internal/collector/credprovider.go; the secretFetcher seam, fetch/extract/cache/redaction/guidance logic in internal/collector/credprovider_secret.go; the production fetchers in internal/collector/credprovider_secret_aws.go (AWS Secrets Manager, GetSecretValue), internal/collector/credprovider_secret_ssm.go (AWS Systems Manager Parameter Store, GetParameter with WithDecryption=true), internal/collector/credprovider_secret_azure.go (Azure Key Vault, azsecrets.GetSecret via the default Azure credential chain), and internal/collector/credprovider_secret_gcp.go (GCP Secret Manager, AccessSecretVersion via ADC). Connection path: direct libpq + verify-full (fetched secret applied as password), reusing the existing BeforeConnect seam. All four backends — AWS Secrets Manager (#97), AWS Systems Manager Parameter Store (#157), Azure Key Vault and GCP Secret Manager (#108) — are production-wired; each secret_ref is routed to exactly its backend's SDK and no other (INV005). Each backend supplies no lease/TTL, so reuse between reconnects is governed entirely by the operator's max_cache_ttl (INV003). Unit tests internal/collector/credprovider_secret_test.go + internal/config/secretref_test.go + internal/config/authmethod_secret_test.go; env-gated live smoke internal/collector/credprovider_secret_live_test.go (//go:build integration, SIGNALS_INTEGRATION_LIVE=1).
| Rule ID | Rule Summary | Test ID(s) | Coverage Status | Evidence Type | Notes |
|---|---|---|---|---|---|
| SIGNALS-AUTH-SECRET-AC-001 | secret_store + verify-full + valid secret_ref fetches the secret, applies it as the password; no inline password required |
TestResolveSecretStoreFetchesAsPassword, TestEffectiveAuthMethodSecretStore, TestValidateAcceptsSecretStore |
COVERED | BEHAVIORAL | Fake fetcher; no real cloud call (NFR003). Asserts backend + region eu-west-1 parsed from ARN. |
| SIGNALS-AUTH-SECRET-AC-002 | Each reference shape selects the right backend (incl. secretsmanager vs ssm ARN split); an unrecognised shape aborts startup with an actionable error naming the four accepted forms |
TestInferSecretBackendShapes, TestInferSecretBackendRejectsUnrecognised, TestProductionSecretFetcherRouting |
COVERED | BEHAVIORAL | FC-SECRET-007. Both AWS ARN regions parsed authoritatively (empty-region / non-parameter ssm ARN rejected). |
| SIGNALS-AUTH-SECRET-AC-002a | Parameter Store fetcher calls GetParameter with WithDecryption=true; SecureString decrypted & String passthrough applied; ttl is zero; no-value parameter fails |
TestAWSParameterStoreFetcherDecryptsAndAppliesValue, TestAWSParameterStoreFetcherNoValueFails, TestAWSParameterStoreFetcherPropagatesError |
COVERED | BEHAVIORAL | #157. Fake SSM client; asserts WithDecryption + ARN as Name; no real AWS call (NFR003). |
| SIGNALS-AUTH-SECRET-AC-003 | With secret_json_key set, the named key's string value is the password; a non-JSON / missing-key / non-string / empty value fails without leaking the raw value |
TestResolveSecretStoreJSONKeyExtraction, TestResolveSecretStoreJSONKeyFailuresDoNotLeak, TestValidateAcceptsSecretStoreWithJSONKey |
COVERED | BEHAVIORAL | FC-SECRET-003 + INV002; raw value/marker asserted absent from every error path. |
| SIGNALS-AUTH-SECRET-AC-004 | Cache bound = min(vault TTL if present, max_cache_ttl if set); neither set → re-fetch every reconnect (rotation pickup); per-target isolation |
TestResolveSecretStoreCacheTTLAndRotation |
COVERED | BEHAVIORAL | INV003/NFR001. 5 subtests: no-bound re-fetch+rotation, vault TTL, max_cache_ttl, min-bound, per-target cache-key isolation. Fake clock + fake fetcher. |
| SIGNALS-AUTH-SECRET-AC-005 | secret_store + any inline password source → hard startup error naming the target |
TestValidateRejectsSecretStoreWithPasswordSource |
COVERED | BEHAVIORAL | FC-SECRET-005 / INV001; covers password_file/password_env/pgpass_file. |
| SIGNALS-AUTH-SECRET-AC-006 | secret_store requires verify-full in every env; rejects weaker modes |
TestValidateRejectsSecretStoreWithoutVerifyFull |
COVERED | BEHAVIORAL | FC-SECRET-006 / INV004 (verify-full floor). |
| SIGNALS-AUTH-SECRET-AC-007 | secret_store without secret_ref, or with an unrecognised one, aborts startup with an actionable error |
TestValidateRejectsSecretStoreWithoutRef, TestValidateRejectsSecretStoreWithBadRef |
COVERED | BEHAVIORAL | FC-SECRET-007. |
| SIGNALS-AUTH-SECRET-AC-008 | Fetch error/timeout → redacted, method+backend-attributable error with an actionable IAM hint; no secret returned/logged; other targets keep collecting | TestResolveSecretStoreFetchErrorIsActionableAndIsolated |
COVERED | BEHAVIORAL | FC-SECRET-001 + INV002; target-scoped, non-fatal to siblings. |
| SIGNALS-AUTH-SECRET-AC-009 | Identity/permission failure → actionable error naming the required grant; no secret leaks | TestResolveSecretStoreIdentityErrorIsActionable |
COVERED | BEHAVIORAL | FC-SECRET-002 + INV002. |
| SIGNALS-AUTH-SECRET-AC-010 | Successful resolution logs metadata only (auth_method/backend/secret_ref/db_user/ttl_present/json_key_extracted), never the secret | TestResolveSecretStoreLogsMetadataNotSecret |
COVERED | BEHAVIORAL | INV002/INV007; log-capture asserts secret value absent. |
| SIGNALS-AUTH-SECRET-AC-011 | Live passwordless connect + read-only snapshot using a real vault fetch (required live content) | TestLive_SecretStorePasswordlessConnect |
COVERED | INTEGRATION | //go:build integration + SIGNALS_INTEGRATION_LIVE=1; all four production fetchers wired, the SIGNALS_TEST_SECRET_REF shape selects which one runs (incl. an ssm Parameter Store ARN). Not in default CI. |
| SIGNALS-AUTH-SECRET-AC-012 | Fetch-failure guidance names the exact IAM grant for the inferred backend + workload-identity note, no secret material | TestSecretStoreGuidanceAWS, TestSecretStoreGuidanceParameterStore |
COVERED | BEHAVIORAL | SecretStoreGuidance; Secrets Manager path names secretsmanager:GetSecretValue, Parameter Store path names ssm:GetParameter + kms:Decrypt; both name the ARN region. |
| SIGNALS-AUTH-SECRET-AC-013 | Live rotation validation (optional, opt-in): rotate the stored secret, force a re-fetch, reconnect with the new value | TestLive_SecretStorePasswordlessConnect |
COVERED | INTEGRATION | Opt-in via SIGNALS_TEST_SECRET_ROTATE=1; rotation-on-reconnect fully covered in default CI by AC-004's unit cache test. |
| SIGNALS-AUTH-SECRET-INV004 (empty secret) | Empty fetched value (or extracted key) is a hard failure, not an empty password | TestResolveSecretStoreEmptySecretFails |
COVERED | BEHAVIORAL | FC-SECRET-004. |
| SIGNALS-AUTH-SECRET-INV005 (backend isolation) | Only the inferred backend's SDK is invoked for a target; an unwired backend reports errSecretBackendUnavailable (not redacted, carries no secret) |
TestProductionSecretFetcherRouting |
COVERED | BEHAVIORAL | productionSecretFetcher routes on ref.Backend; AWS Secrets Manager / AWS Parameter Store / Azure / GCP all wired, each ref reaches only its own fetcher; nil guard still returns unavailable. |
| (design) Azure Key Vault URI parse | Key Vault secret URI splits into vault URL + name + optional version; malformed URIs fail without leaking a parsed name | TestParseAzureKeyVaultRef |
COVERED | BEHAVIORAL | parseAzureKeyVaultRef; pure helper feeding azsecrets.GetSecret. |
Spec: features/signals/credential-provider-mtls.md (ACTIVE), deriving from credential-providers.md (keystone, #93). The only certificate-kind credential: Credential.ClientCert applied to cfg.TLSConfig.Certificates in the BeforeConnect hook (internal/collector/collector.go). Provider + cert loader in internal/collector/credprovider_mtls.go; sslcert/sslkey/sslkey_passphrase_file config + validation in internal/config/config.go. Unit tests internal/collector/credprovider_mtls_test.go + internal/config/authmethod_mtls_test.go.
| Rule ID | Rule Summary | Test ID(s) | Coverage Status | Evidence Type | Notes |
|---|---|---|---|---|---|
| SIGNALS-AUTH-MTLS-AC-001 | Valid cert/key pair loads, applied as certificate kind, ExpiresAt = cert NotAfter; no password |
TestFileCertLoaderLoadsValidPair, TestResolveMTLSCertKindAndExpiry |
COVERED | BEHAVIORAL | In-test self-signed cert; no operator material (NFR003). |
| SIGNALS-AUTH-MTLS-AC-002 | Encrypted key + correct passphrase loads; wrong passphrase fails without echoing passphrase/key | TestFileCertLoaderEncryptedKey |
COVERED | BEHAVIORAL | FC-MTLS-003; stdlib legacy-PEM decryption (NFR001). |
| SIGNALS-AUTH-MTLS-AC-003 | Loader re-reads files each call; rotated cert/key picked up without restart (no cache) | TestFileCertLoaderRotation |
COVERED | BEHAVIORAL | INV-MTLS-003. |
| SIGNALS-AUTH-MTLS-AC-004 | mtls without sslcert/sslkey → hard startup error naming both fields |
TestValidateRejectsMTLSWithoutCertOrKey |
COVERED | BEHAVIORAL | FC-MTLS-001. |
| SIGNALS-AUTH-MTLS-AC-005 | mtls requires verify-full in every env; rejects weaker; accepts verify-full |
TestValidateRejectsMTLSWithoutVerifyFull, TestValidateAcceptsMTLS |
COVERED | BEHAVIORAL | FC-MTLS-004 / INV-MTLS-004. |
| SIGNALS-AUTH-MTLS-AC-006 | mtls + any inline password source → hard startup error (auth is by certificate) |
TestValidateRejectsMTLSWithPasswordSource |
COVERED | BEHAVIORAL | FC-MTLS-005. |
| SIGNALS-AUTH-MTLS-AC-007 | Mismatched pair / non-PEM key → redacted error; key material never in the error | TestFileCertLoaderMismatchedPair, TestFileCertLoaderNonPEMKey |
COVERED | BEHAVIORAL | FC-MTLS-002 + INV-MTLS-001. |
Spec: features/signals/guided-connect.md (ACTIVE). Orchestrates the six stages (detect → resolve → diagnose → role-safety → guidance → emit) over the existing credential providers, conntest, and ValidateRoleSafety (INV003 — reuse, not reimplement). Detection (env-only, no network per NFR001) in internal/guidedconnect/detect.go; the injectable-seam orchestrator (Run, GuidanceFor, renderTargetBlock, appendTarget) in internal/guidedconnect/orchestrator.go. The credential-aware diagnostic is additive: collector.BuildConnConfigWithCredential + the exported collector.CredentialResolver/NewCredentialResolver seam + conntest.TestConnectionWithResolver (shared diagnosePool tail with the production password-only TestConnection, preserving the collector path). MTLSGuidance completes the five-method guidance set. CLI wiring in cmd/signalsctl/connect.go (connectAutoCmd); password fallback prompts only on a TTY, else reports FC006. Unit tests: internal/guidedconnect/detect_test.go, internal/guidedconnect/orchestrator_test.go, internal/collector/connbuild_test.go, internal/conntest/conntest_test.go (resolver path), cmd/signalsctl/connect_auto_test.go. No cloud/network/DB needed in default CI — all seams are faked.
| Rule ID | Rule Summary | Test ID(s) | Coverage Status | Evidence Type | Notes |
|---|---|---|---|---|---|
| SIGNALS-CONNECT-AC001 | A correctly-configured passwordless target connects, passes the read-only safety check, and emits a secret-free targets: block |
TestRun_HappyPath_SecretFreeBlock, TestBuildConnConfigWithCredential_Password, TestBuildConnConfigWithCredential_Certificate |
COVERED | BEHAVIORAL | INV001/INV002; emitted block carries auth_method + sslmode: verify-full, never a credential. |
| SIGNALS-CONNECT-AC002 | Each documented environment (ambient identity + matching host) proposes the documented auth_method; identity-without-host and host-without-identity fall back to password |
TestDetect_Table, TestDetect_HostDisambiguatesMultipleIdentities |
COVERED | BEHAVIORAL | Detection threshold = ambient cloud identity AND matching host pattern (NFR001 env-only). |
| SIGNALS-CONNECT-AC003 | More than one cloud identity with a host matching none is reported ambiguous (with notes), never guessed | TestDetect_Ambiguous, TestRun_AmbiguousReported |
COVERED | BEHAVIORAL | FC001; carries disambiguation notes naming --auth-method. |
| SIGNALS-CONNECT-AC004 | No secret value is ever printed on any path — success, failure, or guidance | TestRun_NoSecretEverPrinted, TestTestConnectionWithResolver_ResolveFailure, TestMTLSGuidance |
COVERED | BEHAVIORAL | INV001; resolver/diagnose detail is redacted; guidance carries no key material. |
| SIGNALS-CONNECT-AC005 | A connect that succeeds but is rejected for a missing grant returns the exact GRANT/role guidance for the detected method |
TestRun_MissingGrantGuidance |
COVERED | BEHAVIORAL | FC004; table-driven across all six methods via GuidanceFor. |
| SIGNALS-CONNECT-AC006 | An over-privileged role fails the read-only safety check and is reported without emitting a config block | TestRun_OverPrivilegedRole |
COVERED | BEHAVIORAL | FC005; reuses ValidateRoleSafety (INV003); no block on failure. |
| SIGNALS-CONNECT-AC007 | --write is dry-run by default; when set it appends a secret-free block to targets: (creating the key if absent) and refuses a duplicate name |
TestRun_DryRunWritesNothing, TestRun_WriteAppendsBlockAndRefusesDuplicate, TestRun_WriteCreatesTargetsKey |
COVERED | BEHAVIORAL | Atomic temp+verify+rename; duplicate-name refusal. |
| SIGNALS-CONNECT-AC008 | Every supported auth_method has a detection path, a connection path, and method-specific guidance |
TestRun_CoversAllMethods, TestMTLSGuidance |
COVERED | BEHAVIORAL | GuidanceFor dispatches to the five provider guidance funcs + password fallback. |
| SIGNALS-CONNECT-INV004 (single method) | Exactly one auth method per run; an explicit --auth-method overrides detection |
TestRun_AuthMethodOverridesDetection |
COVERED | BEHAVIORAL | Override wins over the detector. |
| SIGNALS-CONNECT-FC002 (resolve failure) | An unresolvable credential short-circuits to password_resolve with a redacted detail and no dial |
TestTestConnectionWithResolver_ResolveFailure |
COVERED | BEHAVIORAL | INV001; returns before opening a socket. |
| SIGNALS-CONNECT-FC006 (no password source) | Password method with no TTY and no supplied password is reported, never blocks waiting for input | TestRun_PasswordFallbackNoSource |
COVERED | BEHAVIORAL | Non-interactive by default; prompt only on a TTY. |
| SIGNALS-CONNECT-usage | Missing --user/--host or an unknown --auth-method is a usage error before any detection or dial |
TestRun_RequiresUserAndHost, TestConnectAutoCmd_RequiresUser |
COVERED | BEHAVIORAL | Validated up front; CLI maps to usageError. |
| R116 (owner-only degrade) | An OwnerOnlyDegrade collector that hits 42501 is recorded skipped/privilege_owner_only (not failed); the same error on other collectors stays failed; cycle not partial |
TestClassifyQueryFailureOwnerOnlyPermissionDeniedDegradesToSkipped, TestClassifyQueryFailureOwnerOnlyNonPermissionStaysFailed, TestClassifyQueryFailureNonOwnerPermissionDeniedStaysFailed, TestPrivilegeOwnerOnlyIsNotBudgetExhausted, TestExtStatDataCollectorsAreOwnerOnlyDegrade, TestOrdinaryCollectorIsNotOwnerOnlyDegrade, TestOnlyExtStatDataCollectorsAreOwnerOnlyDegrade |
COVERED | BEHAVIORAL | specifications/owner_only_privilege_degradation.md (#200); pg_statistic_ext_data has PUBLIC SELECT revoked. |
| R117 (advisory dedup) | A persistent per-cycle advisory is logged once per (target, collector, kind) per daemon run, not every poll |
TestWarnOnceDeduplicatesPerTargetQueryKind |
COVERED | BEHAVIORAL | specifications/owner_only_privilege_degradation.md (#200). |
| R118 (export run classification) | Every query_runs.ndjson row carries status/reason verbatim from the persisted columns, so the R116 classification survives the snapshot boundary |
TestExportQueryRunsCarryStatusAndReason, TestExportQueryRunsOwnerOnlySkipDistinguishable, TestExportQueryRunsUnknownStatusVerbatim |
COVERED | BEHAVIORAL | specifications/export-query-run-status.md (#250); TC-EQRS-01..04. |
Spec: specifications/collector-inventory.md (ACTIVE). A committed, machine-readable inventory of registered collector IDs (specifications/collectors/collector-inventory.json), generated from the query registry by cmd/gen-collector-inventory and held in sync by a CI gate. Gives out-of-module consumers (Workbench catalogue generator, Analyzer WPS-R008 freshness checks) a mechanical view of the wire-name enum — spec filenames diverged from registered IDs (family specs, spec-less IDs), which is how the downstream bundled catalogue went stale at 64 of 99 names (Elevarq/Analyzer#1378, Elevarq/Workbench#765). Implementation: internal/pgqueries/inventory.go; tests: internal/pgqueries/inventory_test.go; acceptance: specifications/collector-inventory.acceptance.md.
| Rule ID | Rule Summary | Test ID(s) | Coverage Status | Evidence Type | Notes |
|---|---|---|---|---|---|
| R119 (inventory = registry) | Committed inventory lists exactly the registered IDs with their registered categories, no additions or omissions | TestCollectorInventoryFileInSync, TestCollectorInventoryMatchesRegistry |
COVERED | BEHAVIORAL | TC-CINV-01/03; INV-CINV-01. |
| R120 (canonical encoding) | Byte-stable canonical JSON: sorted by name, sorted keys, two-space indent, single trailing newline, no volatile fields | TestCollectorInventoryCanonicalEncoding, TestCollectorInventoryFileInSync |
COVERED | BEHAVIORAL | TC-CINV-02; regeneration of an unchanged registry is a byte-identical no-op. |
| R121 (CI sync gate) | Registry↔inventory drift fails CI naming the missing/extra IDs; malformed committed bytes fail with a parse error | TestCollectorInventoryDriftIsNamed, TestCollectorInventoryMalformedFile, TestCollectorInventoryFileInSync |
COVERED | BEHAVIORAL | TC-CINV-04/05. |
| R122 (contract version) | contract_version = 1 gates the document schema; changes are deliberate spec amendments |
TestCollectorInventoryCanonicalEncoding |
COVERED | BEHAVIORAL | TC-CINV-02. |
| R123 (#293) | Decision-grade index health (index_health_summary_v2) |
TC-IHV2-01..14 (tests/signals_index_health_summary_v2_test.go) |
COVERED | BEHAVIORAL | Additive collector in internal/pgqueries/catalog_index_health.go; v1 (R103) unchanged. One row per non-system index with explicit safety/constraint/build state and a versioned semantic structure fingerprint. Usage counters (idx_scan/idx_tup_read/idx_tup_fetch) and state booleans emitted raw — never COALESCEd to 0/false (R-IHV2-01). Constraint backing from pg_constraint.conindid -> controlled primary/unique/exclusion/other; build_state from pg_stat_progress_create_index (reindex vs build via left(command,2)='RE', avoiding the CREATE/REINDEX linter keywords) distinguishing active build from invalid_residue/not_ready_residue. structure_fingerprint = md5 over a name/table-independent normalization of pg_get_indexdef plus uniqueness/exclusion facts; exact_duplicate_of set only on full fingerprint equality (R-IHV2-05), prefix_candidate_of a labelled review candidate, never a drop verdict (R-IHV2-06). System schemas excluded (INV-SIGNALS-12); deterministic order; passes the linter; PG14-18 (live-validated on 14 and 18). Unblocks Elevarq/Analyzer#1596. Spec: specifications/collectors/index_health_summary_v2.md. |
| R124 (#294) | Concurrent-DDL capability evidence in index_health_summary_v2 |
TC-IHV2-15 + TC-IHV2-04 (tests/signals_index_health_summary_v2_test.go) |
COVERED | BEHAVIORAL | Extends R123: index_health_summary_v2 emits relation_kind (controlled index/partitioned_index/other) and a strict is_partitioned boolean from pg_class.relkind ('I' -> partitioned parent index -> is_partitioned = true; 'i' -> ordinary / partition-local -> false). relkind is NOT NULL so the fact is always known, never synthesized as false (R-IHV2-08). The analyzer requires is_partitioned = false before DROP INDEX CONCURRENTLY (unsupported on partitioned indexes; https://www.postgresql.org/docs/current/sql-dropindex.html). Partition-local indexes are distinguished from the partitioned parent. Live-validated on PG18 with a partitioned table + parent index + partition-local indexes + an ordinary index. Required by Elevarq/Analyzer#1596. Spec: specifications/collectors/index_health_summary_v2.md. |
| R125 (#340) | No-false-clean export: default scope with no successful data is refused (HTTP 422), never a clean empty ZIP; distinguishes no_collection_yet vs last_collection_failed; --all permissive but marks collection_status |
TestExportRefusesDefaultNoData, TestExportRefusesDefaultFailedCycle, TestExportDefaultWithDataUnchanged, TestExportAllScopeMarksCollectionStatus (tests/signals_export_falseclean_test.go) |
COVERED | BEHAVIORAL | FC-05/INV-SIGNALS-25. Default no-data export returns 422 + export_rejected audit; every emitted metadata.json carries collection_status (ok/no_collection_yet/last_collection_failed). Composes with Analyzer#1885/#1887 consumer-side guards. TC-SIG-024/127/128/129. |
| R126 (#340) | Persisted last-cycle outcome: a fully-failed cycle (cycleStatus=failed) durably records target + failure category + timestamp (no secrets); a later success clears it |
TestFailedCyclePersistsOutcome, TestSuccessfulCycleClearsFailedOutcome, TestCycleOutcomeCarriesNoSecrets (tests/signals_cycle_outcome_test.go) |
COVERED | BEHAVIORAL | INV-SIGNALS-07/25. Migration 005_target_cycle_outcomes.sql; feeds R125's last_collection_failed distinction. TC-SIG-126. |
Spec: specifications/collector-output-contract.md (ACTIVE), with the
supporting invariants in features/signals/appendix-a-api-contract.md
§"Collector output-contract verification (#314)" (INV-OUTPUT-CONTRACT,
INV-CHAR-TEXT-VERIFIED, INV-STATUS-PAYLOAD-VERIFIED). The missing STDD
tests → artifact link: a live-PG integration harness that runs the
full collection against a real PostgreSQL carrying representative schema
(parent + child with an unindexed FK), exports a snapshot ZIP via the
production export.Builder.WriteTo path, reads it back, and asserts the
per-collector output contract for the catalog/schema collectors. This
locks the #312 internal-"char" class permanently — the contype == "f"
assertion is RED on pre-#313 uncast SQL and GREEN after. Test:
tests/signals_collector_output_contract_integration_test.go
(//go:build integration, SIGNALS_TEST_PG_DSN-gated); runs in the CI
PG-version matrix 14/15/16/17/18. Acceptance:
specifications/collector-output-contract.acceptance.md.
#326 extends the harness to the remaining internal-"char" schema
collectors #314 left unexercised: it seeds a partitioned table, a
trigger, and a SQL function and asserts pg_partitions_v1.partition_strategy,
pg_triggers_v1/pg_triggers_definitions_v1.tg_enabled, and
pg_functions_v1/pg_functions_definitions_v1.volatility decode as
single-char strings (OC-R006). When a superuser DSN
(SIGNALS_TEST_PG_SUPERUSER_DSN) provisions the FDW capability it also
seeds a foreign table and asserts fdw_foreign_tables_v1.relkind == "f";
without the capability that leg is a documented skip (OC-R007). This is
test-only — the production normalization already landed in #319/#321.
#316 closes the #314 fast-follow (NFR-02): the per-collector
declared-column assertion (OC-R002) now spans the whole registry, not
only the schema collectors. The declared columns are derived from each
collector's spec ## Output columns table at test time (a spec parser
- a family-source map for the definition-mode / MCV variants), so the
assertion can never drift from the spec (INV-07). New cheap fixtures (a
view, materialized view, enum + composite type, RLS policy, extended
statistics, a SET-config function, a user rule) lift the schema
collectors that were previously zero-row into the asserted set. Every
collector that still emits no rows in the test environment is
enumerated in an explicit zero-row allowlist with a per-collector
reason — contention (
blocking_locks_v1,pg_locks_summary_v1, …), in-flight operations (pg_stat_progress_*), replication (replication_status_v1, …), uninstalled extensions (pg_stat_statements_v1,pg_vector_columns_v1, thetimescaledb_*family), privilege thepg_monitorrole lacks (pg_statistic_ext_data_v1/_mcv_v1), a non-default GUC (pg_prepared_xacts_v1), or rare user-only catalog objects (pg_operators_v1,pg_aggregates_v1,pg_casts_v1,pg_collations_v1,pg_text_search_v1,fdw_user_mappings_v1) — and a collector emitting no rows that is NOT allowlisted fails the harness (OC-R008), so coverage is honest and a newly-added collector cannot slip through unclassified. The assertions are proven real by a mutation spot-check: renamingpg_indexes_v1.indexname's SQL alias turns the sweep RED. No production output-contract violation was found — themissing columnhits during development were all spec-parsing artifacts (definition-mode columns wrongly attributed to inventory collectors, and the version-variant/scalar collectors), resolved by the family-source map and the column-dynamic classification, not by weakening any assertion. Test helper:tests/signals_collector_output_contract_columns.go.
#320 completes the type family the internal-"char" bug (#312) belonged
to. "char" (OID 18) was one member of a class: pgx maps each
PostgreSQL type to a Go type, queryToMaps stores it, and
encoding/json serializes it — so a type whose exported JSON form the
Analyzer misreads is a silent contract violation exactly like char.
char was fixed centrally (#319); #320 audits and locks the remaining
classes by exported JSON shape, cross-checked against the live
Analyzer consumer, in a new sibling harness
TestIntegration_CollectorTypeContractAgainstRealPG
(tests/signals_collector_type_contract_integration_test.go) sharing the
#314/#316/#326 fixture. It was measured, not guessed on a live PG
14–18: numeric→JSON number (pgx pgtype.Numeric; a NaN is the
string "NaN"), jsonb→object (map[string]any), arrays→array
([]any), timestamps→RFC3339 string (time.Time), oid→number
(uint32), bool→true/false. No real mismatch was found — the
char class was uniquely broken (pgx's default QCharCodec returns an
integer) whereas every other class has a correct pgx codec, so #320 adds
no production SQL change; it is a regression lock (OC-R009) plus an
explicit type-class coverage map (OC-R010). Two nuances measurement
caught that guessing would have missed: (a) the redacted FDW option
columns (fdw_options, server_options, foreign_table_options) are
deliberately rendered text[]→map[string]string object by the
redaction post-processor (a contract the Analyzer ingestion test pins),
so they are asserted as objects; (b) pg_policies_v1.permissive is a PG
text column ("PERMISSIVE"/"RESTRICTIVE"), correctly a string, not a
bool. bytea has no collector column and is recorded not-exercised. The
assertions are proven real by mutation spot-checks (asserting a bool as
a number, and a column that emits no non-null value, both go RED). Test
file: tests/signals_collector_type_contract_integration_test.go.
| Rule ID | Rule Summary | Test ID(s) | Coverage Status | Evidence Type | Notes |
|---|---|---|---|---|---|
| OC-R001 (collect against live PG) | Full collection runs against a real PostgreSQL target and exports a snapshot ZIP via the production export path | TestIntegration_CollectorOutputContractAgainstRealPG |
COVERED | INTEGRATION | //go:build integration + SIGNALS_TEST_PG_DSN; CI matrix PG 14-18. Not in default unit CI. |
| OC-R002 (declared columns present) | Every registered collector (#316 — not only catalog/schema) with a non-empty payload has its spec-derived declared columns (parsed from specifications/collectors/<id>.md ## Output columns, never hand-copied) present in each query_results payload object; column-dynamic collectors (SELECT */version-variant/scalar) are row-presence-only |
TestIntegration_CollectorOutputContractAgainstRealPG |
COVERED | INTEGRATION | TC-OC-03; INV-OUTPUT-CONTRACT/INV-07; #316. Mutation-verified RED on a renamed pg_indexes_v1.indexname alias. |
| OC-R003 (char-type is text) | Internal-"char" columns (contype/relkind/relpersistence/provolatile/prokind) decode as single-char strings; seeded FK yields contype == "f" |
TestIntegration_CollectorOutputContractAgainstRealPG |
COVERED | INTEGRATION | TC-OC-01/02; INV-CHAR-TEXT-VERIFIED; the #312 regression lock. Also caught + fixed an uncast pg_class_storage_v1.relpersistence residual. |
| OC-R004 (status↔payload joinable) | Every exported status=success/row_count=N run has exactly one joinable payload with N objects |
TestIntegration_CollectorOutputContractAgainstRealPG |
COVERED | INTEGRATION | TC-OC-04; INV-STATUS-PAYLOAD-VERIFIED; end-to-end proof of INV-SNAP-STATUS-PAYLOAD (#312). |
| INV-SNAP-STATUS-PAYLOAD (retention producer, #327) | Retention cleanup deletes a run's payload + its run row atomically; a failure after the payload delete leaves BOTH tables intact, so no success run is ever stripped of its joinable payload |
internal/db/retention_atomic_test.go (TestDeleteQueryRunsOlderThan_AtomicOnSecondStepFailure, TestDeleteQueryRunsOlderThanByClass_AtomicOnSecondStepFailure, TestDeleteQueryRuns*_InvariantHoldsAfterCleanup) |
COVERED | BEHAVIORAL | FC-23; failure-injection proving the transaction rolls back both deletes on a second-step failure; post-cleanup asserts every remaining success run has exactly one joinable payload with row_count rows. Covers both the legacy flat helper and the per-class production helper. |
| OC-R005 (char-type normalized at connection boundary) | OID 18 decodes as text for ALL collectors via a pooled-connection type registration; the columns #313 missed — relkind (catalog_bloat_v1/catalog_index_bloat_v1/fdw_*_v1), provolatile/volatility (functions), attidentity (identity, "" when non-set) — decode as strings, never JSON numbers |
TestIntegration_CollectorOutputContractAgainstRealPG |
COVERED | INTEGRATION | TC-OC-06; INV-04; the #319 central-fix regression lock (root cause of Elevarq/Analyzer#1871). |
| OC-R006 (char sweep covers remaining schema collectors) | The char whitelist includes the aliases partition_strategy (pg_partitions_v1), tg_enabled (pg_triggers_v1/pg_triggers_definitions_v1), volatility (the output alias of provolatile in pg_functions_v1/pg_functions_definitions_v1), and attidentity; a seeded partitioned table, trigger, and function make each collector emit a single-char string for its aliased char column, never a JSON number |
TestIntegration_CollectorOutputContractAgainstRealPG |
COVERED | INTEGRATION | TC-OC-07; INV-05; regression-locks #319 across the schema collectors #314 missed (#326). |
| OC-R007 (FDW char capability-gated) | When a superuser DSN provisions postgres_fdw + a foreign server + GRANT USAGE, a seeded foreign table makes fdw_foreign_tables_v1 emit relkind == "f" (single-char string, never a number); absent the capability the FDW fixture + assertion are skipped with a documented reason and every other assertion still runs |
TestIntegration_CollectorOutputContractAgainstRealPG |
COVERED | INTEGRATION | TC-OC-08; INV-05/INV-06; capability-gated FDW leg of #326. |
| OC-R008 (no silent coverage gap) | Every registered collector is accounted for: rows-asserted (OC-R002), column-dynamic row-presence, or zero-row allowlisted with a documented reason; a collector emitting no rows that is NOT allowlisted fails the harness naming it; a coverage report enumerates the asserted count + the not-exercised allowlist | TestIntegration_CollectorOutputContractAgainstRealPG |
COVERED | INTEGRATION | TC-OC-09; INV-07; #316. Local PG-17 run: 55/100 declared-column-asserted + 2 column-dynamic row-presence; 43 not-exercised (allowlisted with reasons). |
| OC-R009 (type classes land in Analyzer-expected JSON form) | Each audited PostgreSQL type class beyond internal-"char" (numeric→number, jsonb→object, array→array, FDW-option→object, timestamp→RFC3339 string, oid→number, bool→true/false) is asserted against the exported ZIP read back through encoding/json, exercised by a seeded non-null value; a NaN numeric is the string "NaN" (documented exception); a wrong shape or a vacuous (no non-null) assertion fails the harness |
TestIntegration_CollectorTypeContractAgainstRealPG |
COVERED | INTEGRATION | TC-OC-10; INV-08; the #320 type-fidelity regression lock (general case of the #312 contype bug). Cross-checked vs the live Analyzer consumer (toFloat64/relidToInt64, map[string]any jsonb parsing, []any array coercion, RFC3339 time.Parse, asBool). Mutation-verified RED on a bool-as-number assertion and a no-such-column vacuous assertion. FDW-option-object leg is capability-gated (superuser DSN). |
| OC-R010 (no silent type-class gap) | Every audited type class is accounted for: ≥1 seeded asserted combination, or a recorded not-exercised reason (bytea — no collector column; regclass-text — only on capability-gated collectors); a class with zero asserted combinations and no reason fails the harness; a per-class coverage report is logged |
TestIntegration_CollectorTypeContractAgainstRealPG |
COVERED | INTEGRATION | TC-OC-11; INV-08; #320. Local PG-18 run (FDW capability present): numeric/oid/timestamp/bool/array 5 each, jsonb 3, fdw-option-object 3 asserted; bytea + regclass-text not-exercised. PG-15 run (no FDW): fdw-option-object not-exercised (capability gate), rest asserted. |