Skip to content

Commit 908d344

Browse files
itcmsgrclaude
andauthored
feat(validator): M81-4 per-module health derivation (Day 1+2) (#381)
* feat(validator): M81-4 Day 1 — per-module health evaluator Implements the module health derivation from HEALTH_METRIC_DERIVATION_v1.81.md. Each module evaluated on 4 axes: config, structural, runtime, effective. Added: - module_health.go: evaluateModuleHealth() + per-module evaluators (BotGuard, DDoS, Portscan, LoginMon, Blacklist) - Config readers: readConfigBool() with .local override chain - BotGuard dual-family structural check (Rule 9 per-family aggregation) - StatusIdle enum + evaluateOverallStatus emits idle when all modules idle - SchemaVersionCurrent = "1.81.0" + schema_version in JSON output - ModuleHealthMap, ConfigState, StructuralState, EffectiveState types - BlacklistHealth with manual/feeds/geoban sub-states Gap fixes applied: - StatusIdle added to overall status enum (was missing) - Geoban DB missing = degraded (was stale — per M81-3 contract) - Feeds = loaded when configured (not idle — per shared counter rule) - BotGuard IPv4+IPv6 dual-family evaluation Tests: 32/32 PASS on lab4 (17 new + 15 existing, 0 regressions). Live output verified on lab4 with correct per-module states. Known gaps (Day 2): DDoS counter evidence, blacklist element counting, portscan effective axis stub. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(validator): M81-4 Day 2 — DDoS counter evidence + portscan baseline Day 2 evidence fidelity: effective axis now uses real kernel data. DDoS: - GetCounter() method on RulesetDocument reads named counter packets - evaluateDDoS() checks 5 enforcement counters (ct_ssh/ct_http/ct_mail/ syn_rate/syn_prefix_drop). Any > 0 = ENFORCING. All zero = IDLE. - Live verified on lab4: DDoS now reports "enforcing" from real kernel counter evidence (input_syn_rate_exceeded > 0) Portscan: - Effective axis explicitly set to IDLE with code documentation: no dedicated counter exists, effective evidence incomplete per M81-3 portscan contract. Real enforcement evidence requires kernel log parsing (M81-7 Day 6 scope). - TestPortscanEffectiveAlwaysIdle pinned as regression guard Blacklist: - countSetElements() remains stubbed (nft -j list ruleset does not include set elements — per-set queries needed, future enhancement) - Documentation updated in stub explaining the limitation Overall status: - Now correctly PROTECTED on lab4 (DDoS enforcing = active module) - Was IDLE on Day 1 (no active modules detected) Tests: 35/35 PASS on lab4 (3 new + 32 existing, 0 regressions). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(validator): M81-6 Day 3 — frozen JSON schema via mapper layer Implements the M81-6 JSON_SCHEMA_SPEC_v1.81.md as a separate projection layer between internal state and JSON output. Added: - health_output.go: HealthOutput, ModulesJSON, ModuleJSON, BlacklistJSON, ConsistencyJSON, FindingJSON — the frozen schema types - health_mapper.go: MapToHealthOutput() — single mapping point from ValidationResult to HealthOutput. Enforces: - disabled modules: config only, no other fields - kernel-only modules: no runtime field - daemon-dependent modules: runtime lowercase - no nulls - vocabulary-approved values only - health_mapper_test.go: 10 schema compliance tests including: - schema version check - all 4 status values mapped - disabled omits axes - kernel-only omits runtime - daemon-dependent includes runtime - no-nulls golden test - blacklist composite mapping - consistency stub - findings mapping Changed: - cli.go: ToJSON() now uses MapToHealthOutput() (frozen schema). ToJSONLegacy() preserves raw serialization for rebuild safety code. StatusString() + ExitCode() handle IDLE (exit 0). Backward compat: .status and .chain_counts.total_chains still present in the frozen schema — rebuild safety code in cmd_firewall.sh unaffected. Tests: 45/45 PASS on lab4 (10 new + 35 existing, 0 regressions). Live verified: lab4 emits frozen schema with all M81-6 constraints met. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(cli): M81-5 Day 4 — banned phrase cleanup + regression scan Implements CLI_OUTPUT_SPEC_v1.81.md vocabulary enforcement (partial). Fixed: - cmd_status.sh: "healthy" → "protected" in health_word mapping (4 sites) - cmd_status.sh: "threats_blocked_24h" → "enforcement_events_24h" in JSON output (per M81-5 banned terms + M81-6 frozen schema alignment) - cmd_list.sh: "protecting your IPs" → "in the whitelist" (1 site) Added: - tests/test_banned_phrases.sh: M81-5 regression scanner Scans cli/*.sh for 7 banned phrase patterns. Informational for v1.81 (24 remaining findings in health subsystem). Full enforcement is v1.82. Remaining (v1.82 scope): - "OK" in health context (19 sites, mostly cmd_health_analysis.sh + cmd_health_core.sh — requires health subsystem rewrite) - "healthy" in 2 deeper health paths - "working" in cmd_queue.sh (queue context, low severity) Findings: 27 → 24 (3 fixed, 24 deferred to v1.82 CLI phase) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(validator): CF-1..4 spec-conformance audit fixes for v1.81 release Resolves 4 critical failures from SPEC_CONFORMANCE_AUDIT_v1.81.md. CF-1: service_state.nftband now emits uppercase ("RUNNING"|"STOPPED"| "ERROR") matching JSON_SCHEMA_SPEC §5. Module runtime fields remain lowercase per spec. Fix: removed normalizeRuntime() call for service_state, using raw RuntimeState enum string directly. CF-2: geoban DB missing now emits "stale" (in allowed blacklist sub-state enum) instead of "degraded" (not in enum). Also emits VAL-GEOBAN-001 finding (SeverityWarn) for visibility. New finding code registered in types.go. Module findings collected via moduleFindings slice and appended to result.Findings. CF-3: JSON_SCHEMA_SPEC_v1.81.md updated to mark families and module_truth as "legacy-only (ToJSONLegacy), not part of M81-6 primary schema." The M81-6 HealthOutput replaces module_truth with the richer modules block. No code change — spec alignment only. CF-4: countSetElements() stub documented as v1.81 known limitation with explicit consequence description and v1.82 fix target. BotGuard ENFORCING/OBSERVING and blacklist PRIMED states are unreachable from the validator until per-set queries are implemented. All tests pass on lab4. Live output verified: uppercase service_state, geoban="stale" with VAL-GEOBAN-001 finding, no schema violations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * release: v1.81.0 — metrics alignment and health semantics Bumps VERSION 1.80.1 -> 1.81.0. Full CHANGELOG with: M81-4/5/6 implementation, portscan classic fix, CF-1..4 audit fixes, 12 M81 spec documents, known limitations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 071cab1 commit 908d344

16 files changed

Lines changed: 1757 additions & 36 deletions

CHANGELOG.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
1212
---
1313

14+
## [1.81.0] - 2026-04-14
15+
16+
**Metrics alignment and health semantics implementation.** Module-aware
17+
health output with frozen JSON schema, vocabulary-aligned states, and
18+
CLI/JSON truth discipline. Portscan classic detection bug fixed and
19+
verified live.
20+
21+
### Added
22+
23+
- **M81-4** Per-module health evaluation in Go validator. Each module
24+
evaluated on 4 axes: config, structural, runtime, effective. Truth
25+
tables from `HEALTH_METRIC_DERIVATION_v1.81.md` implemented in code.
26+
Modules: BotGuard, DDoS, Portscan, LoginMon, Blacklist (unified:
27+
manual + feeds + geoban). (PR #381)
28+
- **M81-6** Frozen JSON schema via mapper layer. `MapToHealthOutput()`
29+
is the single projection point — internal state never serialized
30+
directly. Schema version `1.81.0`. No nulls. Vocabulary-approved
31+
values only. (PR #381)
32+
- DDoS effective axis reads real kernel named counters (`input_ct_ssh_drop`,
33+
`input_ct_http_drop`, `input_ct_mail_drop`, `input_syn_rate_exceeded`,
34+
`input_syn_prefix_drop`). Any > 0 = ENFORCING. (PR #381)
35+
- `StatusIdle` enum: overall status distinguishes PROTECTED (at least one
36+
module active) from IDLE (all modules valid but no enforcement). Both
37+
exit 0. (PR #381)
38+
- BotGuard dual-family structural evaluation per Rule 9 (per-family
39+
aggregation). IPv6 checked only if ip6 nftban table exists. (PR #381)
40+
- Consistency block stub in JSON output (`kernel_vs_validator: "ok"`).
41+
Full consistency checking is v1.82 scope. (PR #381)
42+
- `VAL-GEOBAN-001` finding emitted when geoip database missing/empty
43+
and geoban is enabled. (PR #381)
44+
- `test_banned_phrases.sh`: M81-5 regression scanner detecting 7 banned
45+
phrase patterns across CLI files. Informational for v1.81. (PR #381)
46+
47+
### Fixed
48+
49+
- **Portscan classic log-path collision** (CRITICAL). `PORTSCAN_CLASSIC_LOG_FILE`
50+
was defined twice in `classic.conf` — line 32 (kernel input source) and
51+
line 172 (module output log). The detector was grepping its own output
52+
log and finding nothing. Renamed output variable to
53+
`PORTSCAN_CLASSIC_MODULE_LOG`. Detection now verified live: lab2=160/4,
54+
lab4=349/6, monitor=59/1 IPs tracked/blocked. Both background timer and
55+
manual `nftban portscan check` fixed. (PR #377)
56+
- **CF-1** `service_state.nftband` now emits uppercase (`RUNNING`|`STOPPED`|
57+
`ERROR`) matching JSON schema spec. Module runtime fields remain
58+
lowercase. (PR #381)
59+
- **CF-2** Geoban DB missing emits `"stale"` (in allowed enum) instead of
60+
`"degraded"` (not in enum). Emits `VAL-GEOBAN-001` finding. (PR #381)
61+
- **M81-5** CLI banned phrases: `"healthy"` replaced with `"protected"` in
62+
health word mappings. `"threats_blocked_24h"` renamed to
63+
`"enforcement_events_24h"` in status JSON. (PR #381)
64+
65+
### Changed
66+
67+
- `ToJSON()` now uses `MapToHealthOutput()` (frozen schema). Legacy
68+
consumers use `ToJSONLegacy()` for backward compat. (PR #381)
69+
- `ExitCode()` returns 0 for both PROTECTED and IDLE. (PR #381)
70+
71+
### Schema
72+
73+
```json
74+
{
75+
"schema_version": "1.81.0",
76+
"status": "protected|idle|degraded|down",
77+
"service_state": { "nftband": "RUNNING|STOPPED|ERROR" },
78+
"modules": {
79+
"botguard": { "config", "structural", "runtime", "effective" },
80+
"ddos": { "config", "structural", "effective" },
81+
"portscan": { "config", "structural", "effective" },
82+
"loginmon": { "config", "structural", "runtime", "effective" },
83+
"blacklist": { "manual", "feeds", "geoban" }
84+
},
85+
"consistency": { "kernel_vs_validator": "ok|mismatch" },
86+
"findings": [...],
87+
"chain_counts": {...},
88+
"summary": {...}
89+
}
90+
```
91+
92+
### Specs produced (M81-1 through M81-8)
93+
94+
| Spec | Document |
95+
|---|---|
96+
| M81-1 Vocabulary | `NFTBAN_VOCABULARY_REFERENCE_v1.81.md` (v1.1) |
97+
| M81-2 Counter inventory | `METRICS_CATALOG_v1.81.md` |
98+
| M81-3 Module contracts | 5 module evidence contracts |
99+
| M81-4 Health derivation | `HEALTH_METRIC_DERIVATION_v1.81.md` |
100+
| M81-5 CLI output | `CLI_OUTPUT_SPEC_v1.81.md` |
101+
| M81-6 JSON schema | `JSON_SCHEMA_SPEC_v1.81.md` |
102+
| M81-7 Shadowing detection | `SHADOWING_DETECTION_SPEC_v1.81.md` |
103+
| M81-8 Glossary | `METRICS_GLOSSARY_AND_TROUBLESHOOTING_v1.81.md` |
104+
105+
### Known limitations
106+
107+
- **Set-element counting not implemented.** `countSetElements()` returns 0.
108+
BotGuard ENFORCING/OBSERVING and blacklist PRIMED states are unreachable
109+
from the validator. Fix target: v1.82 per-set queries.
110+
- **Portscan effective evidence is structural-only/idle.** No dedicated
111+
kernel counter. Real enforcement evidence requires kernel log parsing.
112+
- **LoginMon effective evidence not yet integrated.** Journal query outside
113+
validator's point-in-time snapshot model. Reports idle by default.
114+
- **Consistency axis is a stub** (`"ok"` always). Full cross-source
115+
checking is v1.82 scope.
116+
- **Legacy shell CLI contains 24 banned-phrase instances** in health
117+
subsystem files. Full CLI vocabulary enforcement is v1.82 scope.
118+
119+
### PRs
120+
121+
| PR | Title |
122+
|---|---|
123+
| #377 | Portscan classic log-path collision fix |
124+
| #381 | M81-4/5/6 health derivation + JSON schema + CLI cleanup + CF fixes |
125+
126+
---
127+
14128
## [1.80.1] - 2026-04-13
15129

16130
**Hotfix.** Fixes validator semantic issue from v1.80.0: module-scoped helper

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.80.1
1+
1.81.0

cli/lib/nftban/cli/cmd_list.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ WHITELIST COMMANDS COMPARISON:
392392
nftban whitelist list # Shows IPs from config files (persistent)
393393
nftban whitelist-system show # Shows only system auto-detected IPs
394394
395-
TIP: Use 'nftban list whitelist' to see what's currently protecting your IPs.
395+
TIP: Use 'nftban list whitelist' to see what's currently in the whitelist.
396396
Use 'nftban whitelist list' to see what's saved in configuration.
397397
398398
EOF

cli/lib/nftban/cli/cmd_status.sh

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -470,11 +470,12 @@ output_brief() {
470470
esac
471471
fi
472472

473+
# M81-5: "healthy" is a banned term. Use "protected" per vocabulary.
473474
case "$_hs" in
474-
OK) health_word="healthy" ;;
475+
OK) health_word="protected" ;;
475476
WARNING*) health_word="info" ;;
476477
ERROR*|CRITICAL*) health_word="errors" ;;
477-
*) health_word="healthy" ;; # Default to healthy if still unknown
478+
*) health_word="protected" ;; # Default to protected if still unknown
478479
esac
479480

480481
# When PROTECTED, health issues are informational not errors
@@ -1636,14 +1637,16 @@ output_json() {
16361637
# Return 0 or query feed config files for count
16371638
echo " \"feed_ips\": 0,"
16381639

1639-
# threats_blocked_24h: Bans in last 24 hours
1640-
local threats_24h=0
1640+
# M81-5/M81-6: renamed from threats_blocked_24h to enforcement_events_24h.
1641+
# "threats blocked" is a banned interpretation per vocabulary.
1642+
# This counter represents enforcement events (bans issued), not threats mitigated.
1643+
local enforcement_24h=0
16411644
if command -v nftban_stats_count_bans >/dev/null 2>&1; then
16421645
local since
16431646
since=$(($(date +%s) - 86400))
1644-
threats_24h=$(nftban_stats_count_bans "$since" 2>/dev/null || echo 0)
1647+
enforcement_24h=$(nftban_stats_count_bans "$since" 2>/dev/null || echo 0)
16451648
fi
1646-
echo " \"threats_blocked_24h\": $threats_24h"
1649+
echo " \"enforcement_events_24h\": $enforcement_24h"
16471650
echo " },"
16481651

16491652
# Master control
@@ -1719,16 +1722,17 @@ output_json() {
17191722
nftban_health_check_all 0 >/dev/null 2>&1 || health_exit=$?
17201723
fi
17211724

1725+
# M81-5: "healthy" is a banned term. Use vocabulary-approved states.
17221726
local health_status="unknown"
17231727
case $health_exit in
1724-
0) health_status="healthy" ;;
1728+
0) health_status="protected" ;;
17251729
1) health_status="warnings" ;;
17261730
2) health_status="errors" ;;
17271731
esac
17281732

17291733
# v1.66.0: JSON health parity — when PROTECTED, health errors are informational
17301734
if [[ "$json_base_state" == "PROTECTED" ]] && [[ "$health_status" == "errors" ]]; then
1731-
health_status="healthy"
1735+
health_status="protected"
17321736
fi
17331737

17341738
# Memory protection state for JSON

cli/lib/nftban/core/nftban_fhs_spec.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
#!/usr/bin/env bash
22
# =============================================================================
3-
# NFTBan v1.80.1 - FHS Specification (GENERATED)
3+
# NFTBan v1.81.0 - FHS Specification (GENERATED)
44
# =============================================================================
55
# SPDX-License-Identifier: MPL-2.0
66
#
77
# meta:name="nftban_fhs_spec"
88
# meta:type="core"
99
# meta:header="FHS Specification"
10-
# meta:version="1.80.1"
10+
# meta:version="1.81.0"
1111
# meta:owner="Antonios Voulvoulis <contact@nftban.com>"
1212
# meta:homepage="https://nftban.com"
1313
#
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# =============================================================================
4+
# NFTBan v1.81 - M81-5 Banned Phrase Detection
5+
# =============================================================================
6+
# meta:name="test_banned_phrases"
7+
# meta:type="test"
8+
# meta:version="1.81.0"
9+
# meta:owner="Antonios Voulvoulis <contact@nftban.com>"
10+
# meta:description="Detects vocabulary-banned phrases in CLI output code per CLI_OUTPUT_SPEC_v1.81.md"
11+
# meta:inventory.files="cli/lib/nftban/cli/*.sh"
12+
# meta:inventory.binaries="bash,grep"
13+
# meta:inventory.env_vars=""
14+
# meta:inventory.config_files=""
15+
# meta:inventory.systemd_units=""
16+
# meta:inventory.network=""
17+
# meta:inventory.privileges="none"
18+
# =============================================================================
19+
# This test scans CLI shell files for phrases banned by M81-5.
20+
# It runs as a static analysis pass — no runtime needed.
21+
#
22+
# Banned phrases are defined in:
23+
# CLI_OUTPUT_SPEC_v1.81.md Section 4
24+
# NFTBAN_VOCABULARY_REFERENCE_v1.81.md Section 2
25+
#
26+
# Severity levels:
27+
# ERROR: phrase found in user-facing output (echo, printf to stdout)
28+
# WARN: phrase found in internal variable assignments
29+
# SKIP: phrase found in comments, test files, or non-output context
30+
#
31+
# This test does NOT block CI in v1.81 (many legacy instances exist).
32+
# It produces a report for incremental cleanup. Full enforcement is v1.82.
33+
# =============================================================================
34+
35+
set -Eeuo pipefail
36+
37+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
38+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
39+
CLI_DIR="$PROJECT_ROOT/cli/lib/nftban/cli"
40+
41+
echo "=========================================================="
42+
echo "M81-5 Banned Phrase Scan"
43+
echo " source: CLI_OUTPUT_SPEC_v1.81.md"
44+
echo " target: cli/lib/nftban/cli/*.sh"
45+
echo "=========================================================="
46+
echo ""
47+
48+
total_findings=0
49+
50+
scan_phrase() {
51+
local phrase="$1"
52+
local description="$2"
53+
local pattern="$3"
54+
55+
local hits
56+
hits=$(grep -rnI "$pattern" "$CLI_DIR"/*.sh 2>/dev/null \
57+
| grep -vE '^\s*#|test_|_test\.|spec\.|SPEC' \
58+
| grep -vE 'systemctl|is-active|ActiveState|ActiveEnter' \
59+
|| true)
60+
61+
local count
62+
count=$(echo "$hits" | grep -c . 2>/dev/null || echo "0")
63+
64+
if [[ "$count" -gt 0 && -n "$hits" ]]; then
65+
echo " FOUND ($count) \"$phrase\"$description"
66+
echo "$hits" | head -5 | sed 's/^/ /'
67+
if [[ "$count" -gt 5 ]]; then
68+
echo " ... and $((count - 5)) more"
69+
fi
70+
total_findings=$((total_findings + count))
71+
else
72+
echo " CLEAN \"$phrase\""
73+
fi
74+
echo ""
75+
}
76+
77+
echo "[1] Vocabulary-banned terms in CLI output"
78+
echo ""
79+
80+
scan_phrase "healthy" \
81+
"banned: use PROTECTED with evidence" \
82+
'"healthy"\|= healthy\|: healthy\|"HEALTHY"'
83+
84+
scan_phrase "working" \
85+
"banned: use specific axis state" \
86+
'"working"\|is working'
87+
88+
scan_phrase "OK (health)" \
89+
"banned in health context: use PROTECTED" \
90+
'"OK"\|= "OK"\|="OK"'
91+
92+
scan_phrase "all clear" \
93+
"banned: absence of evidence != evidence of absence" \
94+
'"all clear"\|all clear'
95+
96+
scan_phrase "no attacks" \
97+
"banned: zero is NEUTRAL, not proof of safety" \
98+
'"no attacks"\|no attacks detected'
99+
100+
scan_phrase "threats blocked" \
101+
"banned: counter interpretation" \
102+
'threats.blocked\|attacks.blocked'
103+
104+
scan_phrase "protecting your" \
105+
"banned: PRESENT != ENFORCING" \
106+
'protecting your\|protects your'
107+
108+
echo "=========================================================="
109+
echo "Total findings: $total_findings"
110+
echo ""
111+
echo "NOTE: This scan is informational for v1.81."
112+
echo "Full enforcement (CI blocking) is v1.82 scope."
113+
echo "=========================================================="
114+
115+
# Exit 0 always — informational, not blocking
116+
exit 0

internal/validator/cli.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,28 @@ func RunValidation(ctx context.Context) (*ValidationResult, error) {
3030
return ValidateKernel(ctx)
3131
}
3232

33-
// ToJSON converts the validation result to JSON.
33+
// ToJSON converts the validation result to the frozen M81-6 JSON schema.
34+
// Uses MapToHealthOutput to enforce the schema contract — never serializes
35+
// the internal ValidationResult directly.
3436
func (r *ValidationResult) ToJSON() ([]byte, error) {
37+
output := MapToHealthOutput(r)
38+
return json.MarshalIndent(output, "", " ")
39+
}
40+
41+
// ToJSONLegacy serializes the raw ValidationResult for backward compat.
42+
// Used by rebuild safety checks that parse the old schema.
43+
// TODO(v1.82): migrate consumers to frozen schema then remove this.
44+
func (r *ValidationResult) ToJSONLegacy() ([]byte, error) {
3545
return json.MarshalIndent(r, "", " ")
3646
}
3747

38-
// StatusString returns a human-readable status with emoji.
48+
// StatusString returns a human-readable status.
3949
func (r *ValidationResult) StatusString() string {
4050
switch r.Status {
4151
case StatusProtected:
4252
return "PROTECTED"
53+
case StatusIdle:
54+
return "IDLE"
4355
case StatusDegraded:
4456
return "DEGRADED"
4557
case StatusDown:
@@ -50,10 +62,11 @@ func (r *ValidationResult) StatusString() string {
5062
}
5163

5264
// ExitCode returns the appropriate exit code for the status.
53-
// 0 = PROTECTED, 1 = DEGRADED, 2 = DOWN
65+
// 0 = PROTECTED or IDLE, 1 = DEGRADED, 2 = DOWN
66+
// Per M81-4: IDLE is exit 0 (not an error).
5467
func (r *ValidationResult) ExitCode() int {
5568
switch r.Status {
56-
case StatusProtected:
69+
case StatusProtected, StatusIdle:
5770
return 0
5871
case StatusDegraded:
5972
return 1

0 commit comments

Comments
 (0)