Skip to content

Commit ee53ffa

Browse files
itcmsgrclaude
andcommitted
feat(v1.100 PR-23): uninstall mutation phase 1 — authority release core
First bounded uninstall mutation path. Replaces the PR-22 auto-elevate shim with explicit consent (--dry-run XOR --confirm-mutation) and adds the kernel + service authority-release sequence. Per locked contract seed: authority release ONLY, no filesystem deletion, no external firewall restoration, no purge matrix. ## Scope (locked 2026-04-20; approved corrections 2026-04-20) - ✅ Kernel: flush + delete ip nftban / ip6 nftban tables - ✅ Service: stop + disable + mask nftband.service - ✅ Safety: emergency SSH injection before mutation, removal after - ✅ End-state validation before emergency teardown **Not in scope** (scope-lock enforced): - ❌ NO filesystem artifact deletion - ❌ NO .conf.local read/write - ❌ NO external firewall restoration (PR-24) - ❌ NO purge vs remove matrix (PR-25) - ❌ NO post-verify gate beyond step 9 (PR-26) - ❌ NO uninstall-history schema (tracked as follow-up — Option A locked) ## Consent model `--mode=uninstall` now requires exactly ONE of: --dry-run : observational plan (unchanged from PR-22) --confirm-mutation : authority release core (PR-23 new) Passing neither is refused. Passing both is refused. This replaces the PR-22 auto-elevate shim that PR-P2-5 (G3-UN-SHIM-LOCK) was designed to force into removal at PR-23 time — the gate's shim_present flips 0 at the same commit mutation_present flips 1. ## Classifier — new AmbiguityKind field (purely additive) `ClassifyResult.Ambiguity` sub-classifies AuthorityAmbiguous so Apply can distinguish recoverable from blocking ambiguity without re- deriving host signals: AmbiguityNone : State != Ambiguous (invariant) AmbiguityOrphanNFTBan : partial nftban + NO external → RECOVERABLE — Apply proceeds via emergency-SSH path to clean up AmbiguityConflictExternal : full nftban + external, OR partial nftban + external, OR multiple externals active → BLOCKING — Apply refuses Consumers read the classifier output rather than re-probing (prevents the divergent-interpretation drift that the audits kept flagging). ## Mutation sequence (10 explicit steps in uninstall/apply.go) 1. Inject emergency SSH safety table (switchop.InjectEmergencySSH) 2. Stop nftband.service (prevent it fighting kernel mutation) 3. Flush ip nftban 4. Flush ip6 nftban (skip if absent) 5. Delete ip nftban 6. Delete ip6 nftban (skip if absent) 7. Disable nftband.service 8. Mask nftband.service (prevent accidental restart) 9. Validate end-state: - no ip/ip6 nftban table - nftband.service not active - emergency SSH table STILL PRESENT (correction 2 — step 10 is what removes it, not step 9) 10. Remove emergency SSH (warn-only on failure) ## Failure mapping (explicit, not implicit) | Step | Kernel state after | Terminal state | |------|-------------------|----------------| | 1 | untouched | StateFailedNoFirewall | | 2 | untouched | log warn, continue | | 3-6 | partial cleanup | StateUninstallFailedRelease | | 7-8 | kernel released | StateDegraded | | 9 | end-state wrong | StateUninstallFailedRelease | | 10 | released + warn | StateUninstallReleased | | all | AuthorityNone | StateUninstallReleased | Every failure branch has a declared post-state; no silent partial success; no silent fallback. ## New state machine terminals - StateUninstallReleased : success; ExitCode → ExitCommitted (0) - StateUninstallFailedRelease : mutation started but incomplete; ExitCode → ExitFailed (2) Both IsApplyTerminal() return true. History write guard in main.go ALSO excludes cfg.mode=="uninstall" so these states never land in update-history.json (Option A — see below). ## History representation (Option A locked 2026-04-20) update-history.json has install-centric status vocabulary (success / install_fail / verify_fail). None truthfully represents uninstall success. Rather than lie (record as "success") or over-engineer (add schema values / new file) inside PR-23, skip entirely: main.go writeHistory guard now reads: if !cfg.dryRun && cfg.mode != "uninstall" && IsApplyTerminal(state) Forensic trail for uninstall events is /var/log/nftban/installer.log (structured step-by-step audit). A dedicated uninstall-history schema is tracked as an explicit follow-up item in contract.md. Not a PR-24 blocker; a truthfulness gap until resolved. ## Files - internal/installer/uninstall/authority.go — AmbiguityKind + field - internal/installer/uninstall/apply.go (NEW) — Apply orchestrator - internal/installer/uninstall/apply_test.go(N) — 7 tests (happy + emergency-inject-fail + flush-mid-fail + mask-fail positive control + orphan- cleanup + ipv6-absent-skip + step-9 invariant; step-9-residual marked Skip pending mock-hook infrastructure) - internal/installer/uninstall/uninstall_test.go — AmbiguityKind tests - internal/installer/state/machine.go — new terminals - internal/installer/state/machine_test.go — terminal mappings - cmd/nftban-installer/flags.go — shim removal + --confirm-mutation - cmd/nftban-installer/main.go — routing + history gate - cmd/nftban-installer/uninstall_apply.go (NEW) — dispatcher - internal/installer/uninstall/contract.md — PR-23 contract doc - .github/workflows/ci-uninstall-canonization.yml — G3-UN-NO-MUTATION narrowed to exclude apply.go (legitimate mutation surface) ## CI gate interaction - G3-UN-SHIM-LOCK: shim_present=0 + mutation_present=1 → PASS (post- PR-23 state). This is the coupling PR-P2-5 was designed to enforce. - G3-UN-NO-MUTATION: scope narrowed with `grep -vE '/apply\.go$'` so the dry-run observational surface remains mutation-free while apply.go is exempt from the forbidden-pattern list. - G3-EXEC-TRACE: unchanged; wraps only the dry-run invocation, not the new confirm-mutation path. - G3-KS-SNAPSHOT: unchanged; dry-run-scoped. - G3-UN-PLAN-RENDERS: unchanged; covers only the dry-run render. The mutation-path gates (real-host evidence of correct authority release) are documented in the PR body as a merge blocker per reviewer checklist §9 — to be captured on lab2 + lab4 before merge. Refs: V1100_LIFECYCLE_COMPLETION_CONTRACT.md §13 (frozen 2026-04-19) internal/installer/uninstall/contract.md §"PR-23 authority release — landed contract" Authorization: locked PR-23 contract seed + reviewer checklist (2026-04-20) with approved corrections (AmbiguityKind split + step-9 emergency-still-present invariant + Option A history) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d420b3 commit ee53ffa

11 files changed

Lines changed: 1070 additions & 54 deletions

File tree

.github/workflows/ci-uninstall-canonization.yml

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,15 +194,29 @@ jobs:
194194
echo "G3-UN-SHIM-LOCK PASS — neither shim nor uninstall mutation present"
195195
fi
196196
197-
- name: G3-UN-NO-MUTATION — structural audit of uninstall package
197+
- name: G3-UN-NO-MUTATION — structural audit of uninstall observational scope
198198
shell: bash
199199
run: |
200200
set -Eeuo pipefail
201-
# Narrow search scope to PR-22's claim surface.
201+
# Narrow search scope to the OBSERVATIONAL uninstall surface.
202+
#
203+
# PR-23 introduced the one legitimate uninstall mutation path
204+
# (internal/installer/uninstall/apply.go — the authority
205+
# release orchestrator). That file is explicitly excluded
206+
# from this grep scope because it MUST contain the kernel +
207+
# service mutation calls the release sequence performs.
208+
#
209+
# G3-UN-SHIM-LOCK (separate gate in this workflow) is the
210+
# invariant that ensures apply.go's mutation cannot coexist
211+
# with the auto-elevate shim. G3-EXEC-TRACE (all 3 workflows)
212+
# catches forbidden mutators at the dry-run path's syscall
213+
# layer. Together those three gates cover the "no mutation
214+
# during dry-run + mutation only in the sanctioned apply
215+
# file" contract.
202216
files=$(find \
203217
internal/installer/uninstall \
204218
cmd/nftban-installer/uninstall_dryrun.go \
205-
-type f -name '*.go' 2>/dev/null || true)
219+
-type f -name '*.go' 2>/dev/null | grep -vE '/apply\.go$' || true)
206220
if [[ -z "$files" ]]; then
207221
echo "::error::G3-UN-NO-MUTATION: uninstall scope files not found"
208222
exit 1

cmd/nftban-installer/flags.go

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ type config struct {
5858
// behind an explicit default-off flag. Operators that relied on the
5959
// prior behaviour must now pass --panel-auto-takeover.
6060
panelAutoTakeover bool // --panel-auto-takeover: allow panel presence to auto-approve takeover (default OFF)
61+
// v1.100 PR-23: --confirm-mutation replaces the PR-22 auto-elevate
62+
// shim. --mode=uninstall now requires exactly one of:
63+
// --dry-run (observational plan)
64+
// --confirm-mutation (authority release core)
65+
// Passing neither is refused; passing both is refused. This
66+
// eliminates the "safe by default" UX drift that the PR-22 scaffold
67+
// had to temporarily tolerate. The G3-UN-SHIM-LOCK CI gate catches
68+
// any regression that reintroduces an auto-elevate path while the
69+
// mutation code is present.
70+
confirmMutation bool // --confirm-mutation: authorize uninstall authority release (PR-23)
6171
}
6272

6373
func parseFlags() *config {
@@ -87,6 +97,8 @@ func parseFlags() *config {
8797
flag.BoolVar(&cfg.restorePriorAuthority, "restore-prior-authority", false, "Restore pre-install external firewall authority. Requires recorded prior-authority record. Plan-only in PR-22.")
8898
// v1.100 PR-22B: explicit panel-auto-takeover gate (see config field doc).
8999
flag.BoolVar(&cfg.panelAutoTakeover, "panel-auto-takeover", false, "Allow control-panel presence to auto-approve takeover of conflicting firewalls. Default OFF. Set explicitly to preserve pre-PR-22B behaviour.")
100+
// v1.100 PR-23: --confirm-mutation — explicit uninstall mutation entry.
101+
flag.BoolVar(&cfg.confirmMutation, "confirm-mutation", false, "Authorize uninstall authority release (real kernel + service mutation). Required for --mode=uninstall without --dry-run. Mutually exclusive with --dry-run.")
90102

91103
flag.Parse()
92104

@@ -146,32 +158,29 @@ func parseFlags() *config {
146158
fmt.Fprintln(os.Stderr, " it has no effect on remove mode and cannot be passed alone.")
147159
os.Exit(state.ExitFatal)
148160
}
149-
// v1.100 PR-22: uninstall mode is accepted; current release
150-
// is detect + dry-run plan only. Mutation phases land in
151-
// PR-23+.
161+
// v1.100 PR-23: auto-elevate shim REMOVED. The operator must
162+
// explicitly choose between:
152163
//
153-
// Audit C regression guard: when PR-23+ adds real mutation,
154-
// this auto-elevation block MUST be removed or changed to
155-
// REFUSE rather than silently elevate. Leaving it in place
156-
// would teach operators that --mode=uninstall is "safe by
157-
// default" — then PR-23 would change that meaning without
158-
// an audit prompt. Tracked in the PR-22 contract doc:
159-
// internal/installer/uninstall/contract.md (audit C regression
160-
// note).
161-
if !cfg.dryRun {
162-
fmt.Fprintln(os.Stderr, "╔══════════════════════════════════════════════════════════════════════╗")
163-
fmt.Fprintln(os.Stderr, "║ --mode=uninstall: NO MUTATION WILL OCCUR (v1.100 PR-22 scope) ║")
164-
fmt.Fprintln(os.Stderr, "║ ║")
165-
fmt.Fprintln(os.Stderr, "║ PR-22 ships detect + dry-run plan only. This invocation is being ║")
166-
fmt.Fprintln(os.Stderr, "║ auto-elevated to --dry-run. Nothing will be removed, no authority ║")
167-
fmt.Fprintln(os.Stderr, "║ released, no service disabled, no file deleted. ║")
168-
fmt.Fprintln(os.Stderr, "║ ║")
169-
fmt.Fprintln(os.Stderr, "║ When PR-23+ adds mutation, this auto-elevation will be removed. ║")
170-
fmt.Fprintln(os.Stderr, "║ At that point, --mode=uninstall will mutate unless --dry-run is ║")
171-
fmt.Fprintln(os.Stderr, "║ explicitly passed. Do not build operational habits around this ║")
172-
fmt.Fprintln(os.Stderr, "║ PR-22 safety-by-default behaviour. ║")
173-
fmt.Fprintln(os.Stderr, "╚══════════════════════════════════════════════════════════════════════╝")
174-
cfg.dryRun = true
164+
// --dry-run : observational plan (zero mutation)
165+
// --confirm-mutation : authority release core (real kernel
166+
// + service mutation)
167+
//
168+
// Passing neither is refused. Passing both is refused. This
169+
// replaces the PR-22 scaffold-era "safe by default" UX with
170+
// explicit consent, closing the audit-C regression seam. The
171+
// G3-UN-SHIM-LOCK CI gate verifies no auto-elevate path
172+
// coexists with uninstall mutation code.
173+
if !cfg.dryRun && !cfg.confirmMutation {
174+
fmt.Fprintln(os.Stderr, "error: --mode=uninstall requires exactly one of --dry-run OR --confirm-mutation")
175+
fmt.Fprintln(os.Stderr, " --dry-run : render the release plan, make no changes")
176+
fmt.Fprintln(os.Stderr, " --confirm-mutation : release nftban authority (real kernel + service mutation)")
177+
fmt.Fprintln(os.Stderr, " explicit consent is required — silent default behaviour was removed in PR-23.")
178+
os.Exit(state.ExitFatal)
179+
}
180+
if cfg.dryRun && cfg.confirmMutation {
181+
fmt.Fprintln(os.Stderr, "error: --dry-run and --confirm-mutation are mutually exclusive")
182+
fmt.Fprintln(os.Stderr, " one asks for a plan; the other authorises mutation. Pick one.")
183+
os.Exit(state.ExitFatal)
175184
}
176185
return cfg
177186
}
@@ -217,6 +226,14 @@ func parseFlags() *config {
217226
os.Exit(state.ExitFatal)
218227
}
219228

229+
// PR-23: --confirm-mutation has no meaning outside --mode=uninstall.
230+
// Reject explicitly so operators don't get a silent no-op or a
231+
// nonsense interaction with install/upgrade takeover semantics.
232+
if cfg.confirmMutation && cfg.mode != "uninstall" {
233+
fmt.Fprintln(os.Stderr, "error: --confirm-mutation is only valid with --mode=uninstall")
234+
os.Exit(state.ExitFatal)
235+
}
236+
220237
// --source is mutually exclusive with packaging-origin flags.
221238
if cfg.source && (cfg.rpm || cfg.deb) {
222239
fmt.Fprintln(os.Stderr, "error: --source cannot be combined with --rpm or --deb")

cmd/nftban-installer/main.go

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,16 @@ func main() {
112112
//
113113
// PR-22B boundary repair: history writes are gated on an explicit
114114
// allowlist of apply-terminal states AND the absence of --dry-run.
115-
// This replaces the earlier mode-name heuristic with a structural
116-
// predicate — dry-runs, intermediate states, and planning-terminal
117-
// states (e.g. StateUninstallPlanning) never produce history entries.
118115
//
119-
// Audit finding F (history / audit-trail truth): update dry-runs used
120-
// to be recorded as install_fail because StateDetectComplete has no
121-
// "success" mapping; now they are not recorded at all. Automation that
122-
// consumes update-history.json is no longer polluted by preview runs.
123-
if !cfg.dryRun && state.IsApplyTerminal(sf.State) {
116+
// PR-23 extension (Option A locked 2026-04-20): uninstall mode is
117+
// ALSO excluded. update-history.json has an install-centric status
118+
// vocabulary (success / install_fail / verify_fail) that cannot
119+
// truthfully represent uninstall success without misrepresenting
120+
// it as an install-success. A dedicated uninstall-history schema
121+
// is an explicit pre-PR-24 (or parallel) follow-up item; until
122+
// that lands, uninstall events are forensically visible only in
123+
// the installer log, and update-history.json stays clean of them.
124+
if !cfg.dryRun && cfg.mode != "uninstall" && state.IsApplyTerminal(sf.State) {
124125
writeHistory(sf, cfg, previousVersion, hostname, log)
125126
}
126127

@@ -156,11 +157,18 @@ func run(ctx context.Context, exec executor.Executor, sf *state.StateFile, cfg *
156157
if cfg.repair {
157158
return runRepair(ctx, exec, sf, log)
158159
}
159-
// v1.100 PR-22 (uninstall scaffold): uninstall-mode short-circuits
160-
// to authority classify + prior-record probe + plan render. No
161-
// mutation code exists in this release; mutation phases land in
162-
// PR-23+. flags.go forces --dry-run for --mode=uninstall in PR-22.
160+
// v1.100 PR-22 / PR-23 uninstall dispatch.
161+
//
162+
// flags.go validation (PR-23) guarantees exactly ONE of dryRun
163+
// or confirmMutation is true for --mode=uninstall, so this two-
164+
// branch routing is exhaustive:
165+
//
166+
// --mode=uninstall --dry-run → observational plan
167+
// --mode=uninstall --confirm-mutation → authority release (PR-23)
163168
if cfg.mode == "uninstall" {
169+
if cfg.confirmMutation {
170+
return runUninstallApply(ctx, exec, sf, cfg, log)
171+
}
164172
return runUninstallDryRun(ctx, exec, sf, cfg, log)
165173
}
166174
// v1.99 PR-16 (G3-U1/U2/U3/U4): update-mode dry-run short-circuits to
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// =============================================================================
2+
// NFTBan v1.100 PR-23 — Uninstall Apply Dispatcher
3+
// =============================================================================
4+
// SPDX-License-Identifier: MPL-2.0
5+
// meta:name="nftban-installer-uninstall-apply"
6+
// meta:type="cmd"
7+
// meta:owner="Antonios Voulvoulis <contact@nftban.com>"
8+
// meta:created_date="2026-04-20"
9+
// meta:description="--mode=uninstall --confirm-mutation dispatcher: preflight + Apply + state transition"
10+
// meta:inventory.files="cmd/nftban-installer/uninstall_apply.go"
11+
// meta:inventory.binaries=""
12+
// meta:inventory.env_vars=""
13+
// meta:inventory.config_files=""
14+
// meta:inventory.systemd_units="nftband.service"
15+
// meta:inventory.network=""
16+
// meta:inventory.privileges="root"
17+
// =============================================================================
18+
//
19+
// This dispatcher is the ONLY entry into uninstall mutation. Reached
20+
// only when:
21+
//
22+
// cfg.mode == "uninstall" AND
23+
// cfg.confirmMutation == true AND
24+
// cfg.dryRun == false
25+
//
26+
// (flags.go rejects any other combination at parse time.)
27+
//
28+
// Responsibilities:
29+
//
30+
// 1. Detect SSH port (reused from install-side detect package).
31+
// 2. Classify current authority (via uninstall.Classify).
32+
// 3. Preflight refusal for non-recoverable states; proceed for
33+
// AuthorityNFTBan or recoverable AuthorityAmbiguous+OrphanNFTBan.
34+
// 4. Invoke uninstall.Apply for the mutation sequence.
35+
// 5. Transition the state file to the Apply result's terminal state.
36+
//
37+
// Emergency SSH: Apply handles the entire inject/validate/remove cycle
38+
// internally. The dispatcher never touches the kernel directly.
39+
//
40+
// History: intentionally NOT written for uninstall mode. main.go's
41+
// writeHistory guard excludes cfg.mode=="uninstall" (Option A locked
42+
// 2026-04-20). Uninstall events are forensically visible only in the
43+
// installer log until a dedicated uninstall-history schema lands in a
44+
// later PR.
45+
//
46+
// =============================================================================
47+
package main
48+
49+
import (
50+
"context"
51+
"fmt"
52+
"os"
53+
54+
"github.com/itcmsgr/nftban/internal/installer/detect"
55+
"github.com/itcmsgr/nftban/internal/installer/executor"
56+
"github.com/itcmsgr/nftban/internal/installer/logging"
57+
"github.com/itcmsgr/nftban/internal/installer/state"
58+
"github.com/itcmsgr/nftban/internal/installer/uninstall"
59+
)
60+
61+
// runUninstallApply orchestrates the PR-23 authority release path.
62+
// Returns the process exit code derived from the final state.
63+
func runUninstallApply(_ context.Context, exec executor.Executor, sf *state.StateFile, _ *config, log *logging.Logger) int {
64+
log.Info("uninstall apply starting (mode=uninstall, confirm-mutation=true)")
65+
66+
// 1. SSH port — needed for the emergency SSH safety table.
67+
sshPort, sshErr := detect.SSHPort(exec, log)
68+
if sshErr != nil {
69+
log.Error("uninstall apply: SSH port detection failed: %v", sshErr)
70+
_ = sf.Transition(state.StateFailedSSH, state.PhaseDetect,
71+
"SSH port detection failed: "+sshErr.Error())
72+
return sf.State.ExitCode()
73+
}
74+
log.Detect("ssh", "port", fmt.Sprintf("%d", sshPort))
75+
sf.SSHPort = sshPort
76+
77+
// 2. Classify authority.
78+
auth := uninstall.Classify(exec, log)
79+
log.Info("uninstall apply: authority=%s ambiguity=%s external=%s",
80+
auth.State, auth.Ambiguity, auth.External)
81+
82+
// 3. Preflight decision table.
83+
proceed := false
84+
var refuseReason string
85+
switch {
86+
case auth.State == uninstall.AuthorityNFTBan:
87+
proceed = true
88+
case auth.State == uninstall.AuthorityAmbiguous && auth.Ambiguity == uninstall.AmbiguityOrphanNFTBan:
89+
// PR-23 correction 1 (locked 2026-04-20): recoverable ambiguity.
90+
// Orphan nftban kernel/service artifacts with no external
91+
// authority observable — Apply proceeds via the emergency-SSH-
92+
// injected cleanup path.
93+
log.Info("uninstall apply: recoverable ambiguity (orphan_nftban) — proceeding with cleanup")
94+
proceed = true
95+
case auth.State == uninstall.AuthorityAmbiguous && auth.Ambiguity == uninstall.AmbiguityConflictExternal:
96+
refuseReason = "authority is ambiguous (external firewall conflict); operator must resolve before uninstall mutation can proceed"
97+
case auth.State == uninstall.AuthorityExternal:
98+
refuseReason = "nftban is not authoritative (" + auth.External + " appears to own the firewall); nothing to release"
99+
case auth.State == uninstall.AuthorityNone:
100+
refuseReason = "no firewall authority detected; nothing to release"
101+
default:
102+
refuseReason = "unknown authority state: " + string(auth.State)
103+
}
104+
105+
if !proceed {
106+
log.Error("uninstall apply: preflight REFUSED — %s", refuseReason)
107+
fmt.Fprintln(os.Stderr, "uninstall apply: preflight refused — "+refuseReason)
108+
_ = sf.Transition(state.StateFailedAbort, state.PhaseDetect, refuseReason)
109+
return sf.State.ExitCode()
110+
}
111+
112+
// 4. Apply the mutation sequence.
113+
result := uninstall.Apply(exec, &uninstall.ApplyConfig{SSHPort: sshPort}, log)
114+
115+
// 5. Persist terminal state. sf.Transition returns a non-nil error
116+
// for failure states (so phase runners halt); here we ignore that
117+
// signal because the dispatcher IS the halt point.
118+
_ = sf.Transition(result.State, state.PhaseSwitch, result.Reason)
119+
120+
// Step-by-step evidence log for operator forensics + CI real-host
121+
// evidence capture. Every step is logged regardless of outcome.
122+
log.Info("uninstall apply: %d steps executed:", len(result.Steps))
123+
for i, s := range result.Steps {
124+
verdict := "ok"
125+
if !s.Success {
126+
verdict = "FAIL"
127+
}
128+
log.Info(" step %d %-28s %s %s", i+1, s.Name, verdict, s.Detail)
129+
}
130+
131+
if result.State == state.StateUninstallReleased {
132+
log.Result("[NFTBan] uninstall: authority released; nftban is no longer authoritative on this host")
133+
} else {
134+
log.Result("[NFTBan] uninstall: %s — %s", result.State, result.Reason)
135+
}
136+
137+
return sf.State.ExitCode()
138+
}

internal/installer/state/machine.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,36 @@ const (
4444
// the planning state so the scope-boundary block in plan output
4545
// remains literally true: no phase beyond Planning exists yet.
4646
StateUninstallPlanning InstallState = "UNINSTALL_PLANNING"
47+
48+
// StateUninstallReleased is the terminal success state for v1.100
49+
// PR-23's uninstall mutation (authority release core). Reached
50+
// after:
51+
// - kernel nftban tables flushed + deleted
52+
// - nftband.service stopped, disabled, masked
53+
// - end-state validation passed (no nftban authority remaining)
54+
// - emergency SSH table cleanly removed
55+
//
56+
// IsApplyTerminal() returns true for this state so downstream
57+
// lifecycle consumers see it as a completed apply outcome.
58+
// ExitCode() maps to ExitCommitted (0) — the operator asked for
59+
// uninstall and got it. However, the uninstall-history
60+
// representation is intentionally SKIPPED for this state in PR-23
61+
// (Option A locked 2026-04-20): update-history.json cannot
62+
// truthfully represent uninstall success under its install-centric
63+
// schema, and the separate-schema work is explicitly deferred to a
64+
// later PR. writeHistory is gated on cfg.mode != "uninstall" in
65+
// main.go, so this state does NOT produce a history entry.
66+
StateUninstallReleased InstallState = "UNINSTALL_RELEASED"
67+
68+
// StateUninstallFailedRelease is the terminal failure state for
69+
// PR-23 when mutation started but did not complete cleanly. The
70+
// emergency SSH table may still be present (over-permissive SSH
71+
// is the deliberate fallback — losing SSH on a failure is a worse
72+
// outcome than a temporary permissive rule). Operator must
73+
// investigate kernel + service state and either retry or manually
74+
// resolve. IsApplyTerminal() returns true; ExitCode() maps to
75+
// ExitFailed (2).
76+
StateUninstallFailedRelease InstallState = "UNINSTALL_FAILED_RELEASE"
4777
)
4878

4979
// Phase represents a named installer phase.
@@ -97,7 +127,16 @@ func (s InstallState) IsApplyTerminal() bool {
97127
StateFailedRender,
98128
StateFailedRebuild,
99129
StateFailedNoFirewall,
100-
StateFailedTakeover:
130+
StateFailedTakeover,
131+
// PR-23: uninstall terminal states represent completed apply
132+
// outcomes too. IsApplyTerminal participates in the
133+
// history-write gate, but the uninstall-history Option A lock
134+
// means main.go's writeHistory call additionally excludes
135+
// cfg.mode=="uninstall" — so these states here are marked
136+
// apply-terminal for lifecycle-bridge consumers without
137+
// triggering install-centric history representation.
138+
StateUninstallReleased,
139+
StateUninstallFailedRelease:
101140
return true
102141
}
103142
return false
@@ -113,7 +152,9 @@ func IsApplyTerminal(s InstallState) bool { return s.IsApplyTerminal() }
113152
func (s InstallState) IsFailed() bool {
114153
switch s {
115154
case StateFailedSSH, StateFailedAbort, StateFailedRender,
116-
StateFailedRebuild, StateFailedNoFirewall, StateFailedTakeover:
155+
StateFailedRebuild, StateFailedNoFirewall, StateFailedTakeover,
156+
// PR-23 uninstall failure terminal.
157+
StateUninstallFailedRelease:
117158
return true
118159
}
119160
return false
@@ -129,6 +170,12 @@ func (s InstallState) ExitCode() int {
129170
switch s {
130171
case StateCommitted:
131172
return ExitCommitted
173+
// PR-23: uninstall success maps to ExitCommitted too. The operator
174+
// asked for uninstall and got it; the process exit code reflects
175+
// operation success, not install-specific success. Install-history
176+
// semantics are kept distinct via the writeHistory mode guard.
177+
case StateUninstallReleased:
178+
return ExitCommitted
132179
case StateDegraded:
133180
return ExitDegraded
134181
case StateFailedAbort:

0 commit comments

Comments
 (0)