Skip to content

Commit 7c9b409

Browse files
itcmsgrclaude
andauthored
feat(v1.100 Amendment 3): code-B — dispatcher wires ExternalIndicator + Amendment-3 OrphanEvidence gathering (#527)
* feat(v1.100 Amendment 3): code-B — dispatcher wires ExternalIndicator + Amendment-3 OrphanEvidence gathering Closes the dispatcher-side wiring gap surfaced by the post-merge audit of Amendment-3-code-A (PR #523). Without this PR, the engine's G1/AmbiguityConflictExternal split (added in PR #523) cannot be reached because the dispatcher does not (a) plumb auth.External into DecisionInput.ExternalIndicator, and (b) trigger gatherOrphanEvidence on the Amendment 3 quintuple shape. cmd/-side wiring only. Decision-layer engine.go untouched. Contract.md untouched. Mutation surfaces unchanged. State machine, exit codes, classifier, and CI all unchanged. Changes: cmd/nftban-installer/restore_decide.go (+50/-13) - DecisionInput initializer now sets ExternalIndicator: auth.External so the engine's §62 entry condition (external == "csf") can be evaluated. - OrphanEvidence-gathering condition extended to recognize EITHER candidate quintuple: * Amendment 2 (§54.3): AuthorityNFTBan + NoRecord + DA + flags * Amendment 3 (§62): AuthorityAmbiguous + ConflictExternal + external=="csf" + NoRecord + DA + flags - Diagnostic log line now reports both predicates (amd2_all_true, amd2_failed_row, amd3_all_true, amd3_failed_row) for observability; the engine consumes whichever is appropriate per the entry path. cmd/nftban-installer/restore_decide_evidence.go (+47/-3) - E.2 reframed per Amendment 3 §64.1: bool is true under EITHER Amendment 2's AuthorityNFTBan entry OR Amendment 3's AuthorityAmbiguous + AmbiguityConflictExternal + external=="csf" entry. Both candidate quintuples produce E.2=true; all other classifier states produce E.2=false (defensive — empty external, non-csf external, multi-external, OrphanNFTBan ambiguity, and AuthorityExternal all fail E.2). - E.13 evaluation tightened to match §54.1 / §64.1 wording exactly ("no AmbiguityOrphanNFTBan" — not the looser "no AuthorityAmbiguous" that conflated the two Amendment 3 ambiguity sub-kinds). Amendment 2 behavior preserved (its entry implies Ambiguity==None which is != OrphanNFTBan). cmd/nftban-installer/restore_decide_amendment3_test.go (NEW, +200) - TestAmd3Dispatcher_E2Reframed_AllTrueAmendment3_True confirms the Amendment 3 entry condition produces E.2=true and AllTrueAmendment3 passes (and Amendment 2's AllTrue() correctly fails because E.12 cannot be true under the §62 entry by construction). - TestAmd3Dispatcher_E2_FalseWhenMisclassified pins 7 classifier shapes: amd2-path-true, amd3-path-true, empty-external-false, ufw-external-false, multi-external-false, OrphanNFTBan-false, AuthorityExternal-false. - TestAmd3Dispatcher_FailedRow_Amendment3 verifies FailedRowIDAmendment3() returns "" on happy path and AMD3-E.7 when E.7 is mutated false. NOT touched: - internal/installer/restore/engine.go (lattice unchanged) - internal/installer/restore/types.go (struct unchanged) - internal/installer/restore/contract.md (no amendment) - internal/installer/uninstall/* (classifier unchanged) - internal/installer/state/* (state machine unchanged) - cmd/nftban-installer/main.go (history gate unchanged) - cmd/nftban-installer/flags.go (flag surface unchanged) - .github/workflows/* (CI unchanged) Test results (lab4): - go test ./cmd/nftban-installer/... → ok - go test ./internal/installer/restore/... → ok - go test ./... → 64 packages ok, 0 FAIL No host action. No binary rebuild. dns2 stays in canonical post-Gate-A state. Unblocks Gate B retry once a fresh Tier 1 binary is built from post-merge HEAD, distributed to dns2, signoff captured, reachability monitor activated, and pre-execution Gate B audit returns GO. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(v1.100 Amendment 3): code-B — split into separate gatherOrphanEvidenceAmendment3 helper per auditor recommendation Per auditor pre-draft review: implement Sub-gap 2a as a separate gatherOrphanEvidenceAmendment3() helper rather than a mode-conditional inside the existing gatherOrphanEvidence(). Mirrors the AllTrue() vs AllTrueAmendment3() split that PR #523 introduced in types.go and the decideAuthorityNFTBan() vs decideAmbiguityConflictExternal() split in engine.go. "Two helpers, shared rows, different entry-condition filling" — symmetric with the engine pattern. Refactor changes (no behavior delta vs the prior code-B commit on this branch): cmd/nftban-installer/restore_decide_evidence.go - populateSharedOrphanEvidenceRows(): private helper populates all rows EXCEPT E.2 (the entry-condition row). - gatherOrphanEvidence(): Amendment 2 — calls shared helper + sets E.2 = (Authority == AuthorityNFTBan). Restored to Amendment-2-byte-clean E.2 evaluation. - gatherOrphanEvidenceAmendment3(): Amendment 3 — calls shared helper + sets E.2 = (AuthorityAmbiguous + ConflictExternal + external == "csf"). New sibling helper. - E.13 retained at the contract-wording-exact form (Ambiguity != AmbiguityOrphanNFTBan); shared by both helpers via the populate function. cmd/nftban-installer/restore_decide.go - Dispatcher's evidence-gathering now switches on the candidate type: amd2Candidate calls gatherOrphanEvidence (unchanged from pre-Amendment-3 main); amd3Candidate calls gatherOrphanEvidenceAmendment3. - Diagnostic log line is now amendment-specific (no dual-predicate noise) — clearer audit trail per amendment. cmd/nftban-installer/restore_decide_amendment3_test.go - Renamed and reorganized test cases to test the two helpers independently: * TestGatherOrphanEvidenceAmendment3_* tests the Amendment 3 helper specifically (rejects Amendment 2 entry, accepts §62 entry, AMD3-E.2 attribution on external=ufw, omits E.12). * TestGatherOrphanEvidence_Amendment2Unchanged regression-tests the Amendment 2 helper: still strict on AuthorityNFTBan, rejects Amendment 3 entry. Why two helpers (auditor reasoning): - Audit-chain clarity: one helper per amendment, mirroring the engine.go and types.go split structure. - Each helper's CI grep gates and tests can reason about it independently. - No runtime classifier-state coupling between the dispatcher's inspection and the evidence-gathering. - Symmetric with AllTrue()/AllTrueAmendment3() in types.go. Test results (lab4): - go test ./cmd/nftban-installer/... → ok - go test ./internal/installer/restore/... → ok - go test ./... → 64 packages ok, 0 FAIL No host action. No engine.go change. No contract.md change. No new mutation surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b81fe3c commit 7c9b409

3 files changed

Lines changed: 430 additions & 18 deletions

File tree

cmd/nftban-installer/restore_decide.go

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,22 +134,56 @@ func runRestoreDecide(ctx context.Context, exec executor.Executor, sf *state.Sta
134134
},
135135
PanelPresent: panelPresent,
136136
Panel: panel,
137+
// Amendment 3 §62 entry condition: the engine consumes the
138+
// classifier's external-authority string so the
139+
// G1/AmbiguityConflictExternal/orphan-intent-candidate-csf
140+
// sub-row can gate on `external == "csf"` (single, exact).
141+
// Carrying it on every restore-mode invocation is cheap; the
142+
// engine ignores it on every lattice path other than the
143+
// Amendment 3 quintuple.
144+
ExternalIndicator: auth.External,
137145
}
138146

139-
// 6b. Amendment 2 §54.3: gather orphan-restore evidence ONLY when
140-
// the candidate triple is otherwise present. This avoids
141-
// unnecessary live reads on every restore-mode invocation, and
142-
// preserves the §51.3 Option B boundary (no iptables introspection)
143-
// by only running the predicate where it matters.
144-
if auth.State == uninstall.AuthorityNFTBan &&
147+
// 6b. Gather orphan-restore evidence ONLY when an orphan-intent
148+
// candidate quintuple is otherwise present. Two entry conditions
149+
// trigger evidence-gathering:
150+
//
151+
// (i) Amendment 2 §54.3 — AuthorityNFTBan + NoRecord + DirectAdmin
152+
// + --panel-auto-takeover + --accept-orphan-nftban
153+
// (ii) Amendment 3 §62 — AuthorityAmbiguous + AmbiguityConflictExternal
154+
// + external=="csf" + NoRecord + DirectAdmin
155+
// + --panel-auto-takeover + --accept-orphan-nftban
156+
//
157+
// Both candidates share the same flag pair, the same panel, the
158+
// same prior state, the same evidence-row set (§54 = §54/§64 except
159+
// E.2 reframed and E.12 omitted — handled by the dispatcher inside
160+
// gatherOrphanEvidence). The dispatcher avoids unnecessary live
161+
// reads on every restore-mode invocation by only running the
162+
// predicate where it matters, preserving the §51.3 Option B
163+
// boundary (no iptables introspection).
164+
amd2Candidate := auth.State == uninstall.AuthorityNFTBan &&
165+
priorState == restore.PriorStateNoRecord &&
166+
panel == detect.PanelDirectAdmin &&
167+
cfg.panelAutoTakeover &&
168+
cfg.acceptOrphanNFTBan
169+
amd3Candidate := auth.State == uninstall.AuthorityAmbiguous &&
170+
auth.Ambiguity == uninstall.AmbiguityConflictExternal &&
171+
auth.External == "csf" &&
145172
priorState == restore.PriorStateNoRecord &&
146173
panel == detect.PanelDirectAdmin &&
147174
cfg.panelAutoTakeover &&
148-
cfg.acceptOrphanNFTBan {
175+
cfg.acceptOrphanNFTBan
176+
switch {
177+
case amd2Candidate:
149178
ev := gatherOrphanEvidence(exec, log, panel, auth, probe, cfg)
150179
input.OrphanEvidence = &ev
151-
log.Info("restore decide: orphan-evidence gathered all_true=%v failed_row=%q",
180+
log.Info("restore decide: orphan-evidence gathered (amendment-2 path) all_true=%v failed_row=%q",
152181
ev.AllTrue(), ev.FailedRowID())
182+
case amd3Candidate:
183+
ev := gatherOrphanEvidenceAmendment3(exec, log, panel, auth, probe, cfg)
184+
input.OrphanEvidence = &ev
185+
log.Info("restore decide: orphan-evidence gathered (amendment-3 path) all_true=%v failed_row=%q",
186+
ev.AllTrueAmendment3(), ev.FailedRowIDAmendment3())
153187
}
154188

155189
// 7. Evaluate — pure, deterministic, no side effects.
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// =============================================================================
2+
// NFTBan v1.100 Amendment 3 — Dispatcher integration tests
3+
// =============================================================================
4+
// SPDX-License-Identifier: MPL-2.0
5+
// meta:name="installer-restore-decide-amendment3-test"
6+
// meta:type="test"
7+
// meta:owner="Antonios Voulvoulis <contact@nftban.com>"
8+
// meta:created_date="2026-04-29"
9+
// meta:description="Amendment 3 dispatcher-side wiring (ExternalIndicator + reframed E.2 + separate helper)"
10+
// meta:inventory.files="cmd/nftban-installer/restore_decide_amendment3_test.go"
11+
// meta:inventory.binaries=""
12+
// meta:inventory.env_vars=""
13+
// meta:inventory.config_files=""
14+
// meta:inventory.systemd_units=""
15+
// meta:inventory.network=""
16+
// meta:inventory.privileges="none"
17+
// =============================================================================
18+
//
19+
// Confirms the dispatcher correctly:
20+
//
21+
// 1. Plumbs ClassifyResult.External into DecisionInput.ExternalIndicator
22+
// so the engine's G1/AmbiguityConflictExternal split sees the
23+
// §62 entry condition string.
24+
//
25+
// 2. Calls the Amendment 3 evidence helper (gatherOrphanEvidenceAmendment3)
26+
// on the Amendment 3 quintuple shape, NOT the Amendment 2 helper
27+
// (auditor-recommended separate-helper structure).
28+
//
29+
// 3. Calls the Amendment 2 evidence helper (gatherOrphanEvidence)
30+
// on the Amendment 2 quintuple shape (regression — Amendment 2
31+
// path unchanged).
32+
//
33+
// 4. Reframes E.2 in the Amendment 3 helper so AllTrueAmendment3()
34+
// returns true on the §62 happy path.
35+
//
36+
// 5. Keeps gatherOrphanEvidence's E.2 strictly evaluating
37+
// AuthorityNFTBan (Amendment 2 unchanged).
38+
//
39+
// =============================================================================
40+
package main
41+
42+
import (
43+
"testing"
44+
45+
"github.com/itcmsgr/nftban/internal/installer/detect"
46+
"github.com/itcmsgr/nftban/internal/installer/logging"
47+
"github.com/itcmsgr/nftban/internal/installer/uninstall"
48+
)
49+
50+
// amd3HappyAuth returns a ClassifyResult shaped for the §62 Amendment 3
51+
// entry condition: AuthorityAmbiguous + AmbiguityConflictExternal +
52+
// external=="csf". Mirrors how dns2 looks post-canonical-install.
53+
func amd3HappyAuth() *uninstall.ClassifyResult {
54+
return &uninstall.ClassifyResult{
55+
State: uninstall.AuthorityAmbiguous,
56+
Ambiguity: uninstall.AmbiguityConflictExternal,
57+
External: "csf",
58+
}
59+
}
60+
61+
// TestGatherOrphanEvidenceAmendment3_E2Reframed_AllTrueAmendment3_True
62+
// confirms the Amendment 3 helper sets E.2=true on the §62 entry
63+
// condition and AllTrueAmendment3() returns true on the happy path.
64+
// Also verifies E.12 is correctly false (entry condition IS
65+
// AmbiguityConflictExternal) and that AllTrue() (Amendment 2 predicate)
66+
// returns false on this fixture (because E.12 is required-true by §54.1).
67+
func TestGatherOrphanEvidenceAmendment3_E2Reframed_AllTrueAmendment3_True(t *testing.T) {
68+
log := logging.New("/dev/null", false)
69+
ev := gatherOrphanEvidenceAmendment3(happyExec(), log, detect.PanelDirectAdmin, amd3HappyAuth(), happyProbe(), happyCfg())
70+
71+
if !ev.E2AuthorityNFTBan {
72+
t.Errorf("E.2 = false on Amendment 3 entry condition; want true (reframed per §64.1)")
73+
}
74+
if !ev.AllTrueAmendment3() {
75+
t.Errorf("AllTrueAmendment3() = false; failed row=%s", ev.FailedRowIDAmendment3())
76+
}
77+
if ev.E12NoConflictExternal {
78+
t.Errorf("E.12 = true on Amendment 3 entry; want false (entry condition IS AmbiguityConflictExternal)")
79+
}
80+
if ev.AllTrue() {
81+
t.Errorf("AllTrue() = true on Amendment 3 entry; want false (Amendment 2 predicate requires E.12=true)")
82+
}
83+
}
84+
85+
// TestGatherOrphanEvidenceAmendment3_E2FalseOnNonQualifying confirms
86+
// that the Amendment 3 helper's E.2 is conservative: ONLY fires when
87+
// the full §62 entry condition (AuthorityAmbiguous + ConflictExternal
88+
// + external=="csf") is present.
89+
func TestGatherOrphanEvidenceAmendment3_E2FalseOnNonQualifying(t *testing.T) {
90+
cases := []struct {
91+
name string
92+
auth *uninstall.ClassifyResult
93+
want bool
94+
}{
95+
{
96+
name: "amd3_path_authority_ambiguous_csf",
97+
auth: &uninstall.ClassifyResult{
98+
State: uninstall.AuthorityAmbiguous,
99+
Ambiguity: uninstall.AmbiguityConflictExternal,
100+
External: "csf",
101+
},
102+
want: true,
103+
},
104+
{
105+
name: "amd2_path_authority_nftban_FAILS_amendment3_helper",
106+
auth: &uninstall.ClassifyResult{
107+
State: uninstall.AuthorityNFTBan,
108+
Ambiguity: uninstall.AmbiguityNone,
109+
External: "",
110+
},
111+
want: false, // gatherOrphanEvidenceAmendment3 ONLY accepts §62 entry
112+
},
113+
{
114+
name: "external_empty_defensive",
115+
auth: &uninstall.ClassifyResult{
116+
State: uninstall.AuthorityAmbiguous,
117+
Ambiguity: uninstall.AmbiguityConflictExternal,
118+
External: "",
119+
},
120+
want: false,
121+
},
122+
{
123+
name: "external_ufw_out_of_scope",
124+
auth: &uninstall.ClassifyResult{
125+
State: uninstall.AuthorityAmbiguous,
126+
Ambiguity: uninstall.AmbiguityConflictExternal,
127+
External: "ufw",
128+
},
129+
want: false,
130+
},
131+
{
132+
name: "external_multi_csf_ufw",
133+
auth: &uninstall.ClassifyResult{
134+
State: uninstall.AuthorityAmbiguous,
135+
Ambiguity: uninstall.AmbiguityConflictExternal,
136+
External: "csf,ufw",
137+
},
138+
want: false,
139+
},
140+
{
141+
name: "ambiguity_orphan_nftban",
142+
auth: &uninstall.ClassifyResult{
143+
State: uninstall.AuthorityAmbiguous,
144+
Ambiguity: uninstall.AmbiguityOrphanNFTBan,
145+
External: "csf",
146+
},
147+
want: false,
148+
},
149+
{
150+
name: "authority_external",
151+
auth: &uninstall.ClassifyResult{
152+
State: uninstall.AuthorityExternal,
153+
External: "csf",
154+
},
155+
want: false,
156+
},
157+
}
158+
159+
for _, c := range cases {
160+
t.Run(c.name, func(t *testing.T) {
161+
log := logging.New("/dev/null", false)
162+
ev := gatherOrphanEvidenceAmendment3(happyExec(), log, detect.PanelDirectAdmin, c.auth, happyProbe(), happyCfg())
163+
if ev.E2AuthorityNFTBan != c.want {
164+
t.Errorf("E.2 = %v; want %v", ev.E2AuthorityNFTBan, c.want)
165+
}
166+
})
167+
}
168+
}
169+
170+
// TestGatherOrphanEvidence_Amendment2Unchanged confirms the Amendment 2
171+
// helper's E.2 still evaluates strictly AuthorityNFTBan. This is the
172+
// regression check for §66.2 — Amendment 2 path stays passing.
173+
func TestGatherOrphanEvidence_Amendment2Unchanged(t *testing.T) {
174+
cases := []struct {
175+
name string
176+
auth *uninstall.ClassifyResult
177+
want bool
178+
}{
179+
{
180+
name: "amd2_authority_nftban_true",
181+
auth: &uninstall.ClassifyResult{
182+
State: uninstall.AuthorityNFTBan,
183+
Ambiguity: uninstall.AmbiguityNone,
184+
},
185+
want: true,
186+
},
187+
{
188+
name: "amd3_classifier_does_NOT_qualify_amendment2_E2",
189+
auth: &uninstall.ClassifyResult{
190+
State: uninstall.AuthorityAmbiguous,
191+
Ambiguity: uninstall.AmbiguityConflictExternal,
192+
External: "csf",
193+
},
194+
want: false, // Amendment 2 helper rejects Amendment 3 entry
195+
},
196+
}
197+
for _, c := range cases {
198+
t.Run(c.name, func(t *testing.T) {
199+
log := logging.New("/dev/null", false)
200+
ev := gatherOrphanEvidence(happyExec(), log, detect.PanelDirectAdmin, c.auth, happyProbe(), happyCfg())
201+
if ev.E2AuthorityNFTBan != c.want {
202+
t.Errorf("E.2 = %v; want %v", ev.E2AuthorityNFTBan, c.want)
203+
}
204+
})
205+
}
206+
}
207+
208+
// TestGatherOrphanEvidenceAmendment3_FailedRow confirms
209+
// FailedRowIDAmendment3() returns AMD3-E.{N} on per-row failures and
210+
// "" on the happy path. E.12 is omitted from the Amendment 3 walk so
211+
// its falseness must not trigger a failed-row return.
212+
func TestGatherOrphanEvidenceAmendment3_FailedRow(t *testing.T) {
213+
log := logging.New("/dev/null", false)
214+
215+
ev := gatherOrphanEvidenceAmendment3(happyExec(), log, detect.PanelDirectAdmin, amd3HappyAuth(), happyProbe(), happyCfg())
216+
if id := ev.FailedRowIDAmendment3(); id != "" {
217+
t.Errorf("FailedRowIDAmendment3 on happy path = %q; want \"\"", id)
218+
}
219+
220+
exec := happyExec()
221+
delete(exec.Files, "/usr/sbin/csf.disabled")
222+
ev = gatherOrphanEvidenceAmendment3(exec, log, detect.PanelDirectAdmin, amd3HappyAuth(), happyProbe(), happyCfg())
223+
if id := ev.FailedRowIDAmendment3(); id != "AMD3-E.7" {
224+
t.Errorf("FailedRowIDAmendment3 with E.7 absent = %q; want %q", id, "AMD3-E.7")
225+
}
226+
}
227+
228+
// TestGatherOrphanEvidenceAmendment3_E2_FalseOnNonCSFExternal pins
229+
// the AMD3-7-equivalent gating: external != "csf" → E.2 = false →
230+
// AllTrueAmendment3 false → FailedRowIDAmendment3 == AMD3-E.2.
231+
func TestGatherOrphanEvidenceAmendment3_E2_FalseOnNonCSFExternal(t *testing.T) {
232+
log := logging.New("/dev/null", false)
233+
auth := &uninstall.ClassifyResult{
234+
State: uninstall.AuthorityAmbiguous,
235+
Ambiguity: uninstall.AmbiguityConflictExternal,
236+
External: "ufw",
237+
}
238+
ev := gatherOrphanEvidenceAmendment3(happyExec(), log, detect.PanelDirectAdmin, auth, happyProbe(), happyCfg())
239+
if ev.E2AuthorityNFTBan {
240+
t.Errorf("E.2 = true on external=ufw; want false")
241+
}
242+
if ev.AllTrueAmendment3() {
243+
t.Errorf("AllTrueAmendment3 = true on external=ufw; want false")
244+
}
245+
if id := ev.FailedRowIDAmendment3(); id != "AMD3-E.2" {
246+
t.Errorf("FailedRowIDAmendment3 on external=ufw = %q; want %q", id, "AMD3-E.2")
247+
}
248+
}
249+
250+
// TestGatherOrphanEvidenceAmendment3_OmitsE12 carries the §66.2
251+
// invariant from the engine_amendment3_test.go suite into the
252+
// dispatcher integration: AllTrueAmendment3() must NOT consider
253+
// E.12 even when it is false.
254+
func TestGatherOrphanEvidenceAmendment3_OmitsE12(t *testing.T) {
255+
log := logging.New("/dev/null", false)
256+
ev := gatherOrphanEvidenceAmendment3(happyExec(), log, detect.PanelDirectAdmin, amd3HappyAuth(), happyProbe(), happyCfg())
257+
258+
// On Amendment 3 entry, E.12 is false (entry IS ConflictExternal).
259+
if ev.E12NoConflictExternal {
260+
t.Fatalf("invariant: E.12 must be false on Amendment 3 entry; got true")
261+
}
262+
// AllTrueAmendment3 must still pass — E.12 omitted from predicate.
263+
if !ev.AllTrueAmendment3() {
264+
t.Errorf("AllTrueAmendment3 = false despite E.12 omission; failed row=%s", ev.FailedRowIDAmendment3())
265+
}
266+
}

0 commit comments

Comments
 (0)