Skip to content

feat(v1.100 PR-24): authority restoration policy decision engine#494

Merged
itcmsgr merged 1 commit intomainfrom
feat/v1.100-pr-24-restore-policy-engine
Apr 20, 2026
Merged

feat(v1.100 PR-24): authority restoration policy decision engine#494
itcmsgr merged 1 commit intomainfrom
feat/v1.100-pr-24-restore-policy-engine

Conversation

@itcmsgr
Copy link
Copy Markdown
Owner

@itcmsgr itcmsgr commented Apr 20, 2026

Implements the pure decision engine locked by PR #493 (contract seed). PR-24 is POLICY ONLY — zero kernel, service, or filesystem mutation. Refusal and intent-required are valid and expected outcomes.

Scope (locked per seed §2)

  • internal/installer/restore/ decision engine (pure function over 4 axes)
  • --mode=restore CLI dispatcher
  • ✅ State-machine entries: StateRestoreRefused, StateRestoreIntentRequired (terminal, non-failed, non-apply-terminal) + StateRestoreDecided (non-terminal handoff marker, per seed §7 locked constraints)
  • ✅ Exit codes: ExitRefused=5, ExitIntentRequired=6
  • ✅ 4 CI gates in G4-RESTORE-* namespace
  • No kernel mutation
  • No service mutation
  • No filesystem writes (static-scan enforced)
  • No history write (IsApplyTerminal=false + cfg.mode != "restore" gate)
  • No restoration-execution code (belongs to PR-25+)

Lattice (seed §6 v2 + locked amendments)

Top-down precedence (§5, load-bearing):

  1. Classifier hard-stops
  2. Input / flag validity
  3. Prior-record integrity gates
  4. Panel context gates
  5. Proceed decisions

Locked amendments implemented:

  • NoRecord + --restore → REQUIRE_EXPLICIT_INTENT (not PROCEED — --restore requires a recorded target)
  • Legacy prior records missing ActiveAtInstall → classified as Incomplete by uninstall.Probe (PR-P2-1) → flows to REQUIRE_EXPLICIT_INTENT
  • Staleness window fixed at 365 days (seed §3.B); configurability deferred
  • StateRestoreDecided non-terminal, non-apply-terminal, excluded from history, not evidence of restoration

Output discipline

Closed Go enum of exactly three values:

OutputProceed               Output = "PROCEED"
OutputRefuse                Output = "REFUSE"
OutputRequireExplicitIntent Output = "REQUIRE_EXPLICIT_INTENT"

No fourth value. No default branch. Unreached code paths panic() as contract-regression guards (CI catches them).

Tests

Fixture matrix in internal/installer/restore/engine_test.go:

  • TestDecide_FixtureMatrix — 24 rule-path fixtures (every declared rule exercised)
  • TestRuleCoverage_EveryRuleExercised — bidirectional coverage assertion (declared ↔ fixtures)
  • TestDecide_Determinism — reflect.DeepEqual on back-to-back evaluations
  • TestDecide_OutputClosedEnum — no path emits outside the 3-value enum
  • TestDecide_LockedAmendment_NoRecordRestoreRequiresIntent — explicit guard for the locked amendment in both AuthorityNone + AmbiguityOrphanNFTBan paths
  • TestDecide_HardStopsDominateAnyFlag — exhaustive matrix: 3 hard-stops × 4 flag combos × 5 priors × 2 panel states = 120 cells per hard-stop, all must REFUSE
  • TestDecide_OrphanPanelAutoRefused — orphan + --panel-auto-takeover REFUSES under every prior

CI gates

.github/workflows/ci-restore-canonization.yml (matrix: ubuntu-24.04 + almalinux-9):

Gate What it asserts
G4-RESTORE-NO-IMPLICIT-EXEC Static scan: restore package has zero forbidden symbols (exec.*, os.Create/WriteFile/Rename/Remove/Mkdir, nft/iptables/systemctl literal calls, service helpers, rebuild/switchop mutation helpers)
G4-RESTORE-DECISION-CORRECTNESS Runs the full fixture matrix + rule-path coverage assertion
G4-RESTORE-REFUSAL-INTEGRITY Structural layering: restore package does not import executor; dispatcher does not import switchop/services
G4-RESTORE-DETERMINISM Two independent test runs; normalized output diff must be empty

No G3 gate weakened. Carry-forward uninstall gates (G3-UN-SHIM-LOCK, G3-UN-NO-MUTATION, G3-EXEC-TRACE, G3-KS-SNAPSHOT) continue to apply.

Dispatcher flow (cmd/nftban-installer/restore_decide.go)

  1. uninstall.Classify → authority + ambiguity (single source of truth)
  2. uninstall.Probe → prior-record (PR-P2-1 hardened schema)
  3. Reduce probe + 365-day window → restore.PriorState normalized enum
  4. detect.DetectPanel → panel type → boolean
  5. Assemble DecisionInput
  6. restore.Decide(input) — pure, no side effects
  7. Log structured decision-path record (inputs + matched rule + output)
  8. sf.Transition to terminal / non-terminal state
  9. Return exit code

Preflight errors (malformed prior record, classifier Ambiguous without sub-kind) → ExitFatal=4 — does NOT emit a lattice output, keeping the three-output space closed per seed §9.

Reviewer checklist (merge-blocking, per seed §13 + code-phase §6)

Policy correctness:

  • Every classifier state handled (no default branch)
  • Every prior-record state handled
  • Group 1 hard-stops dominate all flag/panel inputs
  • Panel context never causes proceed without --panel-auto-takeover
  • NoRecord + --restore returns REQUIRE_EXPLICIT_INTENT
  • Legacy records missing ActiveAtInstallIncompleteREQUIRE_EXPLICIT_INTENT
  • Staleness window fixed at 365 days (not configurable in PR-24)

Safety:

  • AuthorityExternal never overridden
  • AmbiguityConflictExternal never overridden
  • Orphan + --panel-auto-takeoverREFUSE
  • No auto-upgrade path from REQUIRE_EXPLICIT_INTENT to PROCEED

Purity:

  • Zero kernel interaction
  • Zero service interaction
  • Zero filesystem writes in the restore package (G4-RESTORE-NO-IMPLICIT-EXEC green)
  • update-history.json schema unchanged
  • No executor dependency in restore package (G4-RESTORE-REFUSAL-INTEGRITY green)

Output discipline:

  • Output is a closed Go enum of three
  • StateRestoreDecided excluded from IsApplyTerminal and from history
  • Exit codes distinct: ExitRefused=5, ExitIntentRequired=6

Evidence:

  • All 4 G4 gates green on both DEB + RPM matrix
  • lab4 decision-only evidence (bare → REQUIRE_EXPLICIT_INTENT; --restore-prior-authority → REQUIRE_EXPLICIT_INTENT; exec-trace clean)
  • lab2 supplementary evidence (bare → REFUSE under G1/AuthorityNFTBan; exec-trace clean)

Real-host evidence (2026-04-20)

Required decision-only host matrix satisfied. Supplementary AuthorityNFTBan → REFUSE branch also proven on real host. Remaining dangerous branches (AuthorityExternal, AmbiguityConflictExternal, AmbiguityOrphanNFTBan, weak-record, panel-driven proceed) are fixture-only per seed §11 — simulating them at kernel level would violate the no-mutation gate via the test harness itself.

Binary provenance

Frozen CI artifact from commit 7330f8cb:

sha256 = 0cfe8db8f4192dfeefcd0e957cf5314e42ad8194ee8f665ace322d27847f5896

Identical binary used on both hosts.

lab4 — AlmaLinux 9.7 / RPM (required matrix)

Host state: AuthorityNone + NoRecord (released post-PR-23, never reinstalled).

Case Rule Output Exit Mutation spawns
--mode=restore (bare) G3.3/NoRecord+NoFlag REQUIRE_EXPLICIT_INTENT 6 0
--mode=restore --restore-prior-authority G3.3/NoRecord+Restore REQUIRE_EXPLICIT_INTENT 6 0

Terminal state on both: RESTORE_INTENT_REQUIRED. Panel: cpanel (informational; Group 3.3 bare/restore paths do not depend on panel context).

lab2 — Ubuntu 24.04 / DEB (supplementary)

Host state: AuthorityNFTBan (from PR-23 reinstall round-trip; intentionally not forced into AuthorityNone).

Case Rule Output Exit Mutation spawns
--mode=restore (bare) G1/AuthorityNFTBan REFUSE 5 0

Terminal state: RESTORE_REFUSED. Panel: plesk (informational; Group 1 dominates regardless of panel).

Purity proof (all three invocations)

Process spawns observed via strace -f -e trace=execve:

/usr/bin/systemctl       ← is-active / is-enabled probes (READ-ONLY)
/usr/sbin/iptables-save  ← ghost-rule count probe (READ-ONLY)
/usr/sbin/nft            ← list tables / list table probes (READ-ONLY)

Zero spawns of:

  • nft with add/flush/delete/create/insert/replace
  • systemctl with start/stop/enable/disable/mask/unmask/reload/restart/daemon-reload
  • iptables/ip6tables/ufw/firewall-cmd (any args)

Host-state invariance

Host Signal BEFORE AFTER
lab4 nft list tables empty empty
lab4 nftband.service inactive + masked inactive + masked
lab2 nft list tables inet filter + ip nftban + ip6 nftban identical
lab2 nftband.service active + enabled active + enabled

Zero host-state change across all three invocations.

Test plan

  • Build & Test green — fixture matrix + coverage + determinism + closed-enum + locked-amendment + hard-stop dominance + orphan+panel-auto refusal
  • Restore Canonization Gate green — G4-RESTORE-NO-IMPLICIT-EXEC + G4-RESTORE-DECISION-CORRECTNESS + G4-RESTORE-REFUSAL-INTEGRITY + G4-RESTORE-DETERMINISM across DEB + RPM matrix
  • All existing gates green (no G3 weakening)
  • lab4 decision-only evidence (required matrix)
  • lab2 supplementary evidence (AuthorityNFTBan → REFUSE)

Follow-up items (seed §15, carried forward, not blocking PR-24)

  1. ActiveAtInstall capture in new prior-record writes
  2. Staleness-window configurability knob
  3. Uninstall-history schema decision (carried from PR-23)
  4. Panel-auto + prior-record-identity consistency (revisit in PR-25)

🤖 Generated with Claude Code

Implements the pure decision engine defined by the merged contract seed
(PR #493). PR-24 is POLICY ONLY — no kernel, service, or filesystem
mutation, no history write, no restore execution code.

Scope (locked per seed §2):
- internal/installer/restore/ decision engine
- --mode=restore CLI dispatcher
- state-machine entries: StateRestoreRefused, StateRestoreIntentRequired
  (terminal, non-failed, non-apply-terminal); StateRestoreDecided
  (non-terminal handoff marker)
- exit codes: ExitRefused=5, ExitIntentRequired=6 (distinct from
  generic failure to enable scriptability)
- 4 CI gates in G4-RESTORE-* namespace

Lattice (seed §6 v2 + locked amendments):
- Top-down precedence: classifier hard-stops → input validity →
  prior-record integrity → panel gates → proceed decisions
- AuthorityNFTBan / AuthorityExternal / AmbiguityConflictExternal →
  REFUSE absolutely (no flag may override)
- NoRecord + --restore → REQUIRE_EXPLICIT_INTENT (locked amendment:
  no implicit target)
- NoRecord + --panel-auto-takeover + panel → PROCEED (panel-auto
  carries its own target)
- Complete + ActiveAtInstall=false → REQUIRE_EXPLICIT_INTENT (any
  flag; restore semantics ambiguous between preserve-inactive and
  activate)
- Stale / Incomplete → REQUIRE_EXPLICIT_INTENT
- Orphan + --panel-auto-takeover → REFUSE (panel-auto must not fire
  over nftban residue)
- Staleness window fixed at 365 days (seed §3.B; configurability is a
  deferred follow-up)

Output is a closed enum of three values: PROCEED, REFUSE,
REQUIRE_EXPLICIT_INTENT. No fourth output; no default branch; no
fallthrough. Unreached code paths panic as contract-regression guards.

Tests (internal/installer/restore/engine_test.go):
- Rule-path coverage matrix — every Rule* constant must have a fixture
- Determinism — reflect.DeepEqual on two back-to-back evaluations
- Closed-enum output invariant
- Locked-amendment guards (NoRecord + --restore in both AuthorityNone
  and AmbiguityOrphanNFTBan paths)
- Hard-stop dominance — Group 1 refuses under every flag / prior /
  panel combination (4 * 5 * 2 * 3 = 120 cells per hard-stop)
- Orphan + panel-auto refusal under every prior state

CI gates (.github/workflows/ci-restore-canonization.yml):
- G4-RESTORE-NO-IMPLICIT-EXEC — static scan for forbidden symbols
  (exec.*, nft/iptables/systemctl literal calls, service helpers,
  os.Create/WriteFile/Rename/Remove/Mkdir, rebuild/switchop/services
  mutation helpers)
- G4-RESTORE-DECISION-CORRECTNESS — runs the full fixture matrix
  including rule-path coverage assertion
- G4-RESTORE-REFUSAL-INTEGRITY — structural check that restore package
  has no executor dependency AND dispatcher does not import mutation-
  capable packages
- G4-RESTORE-DETERMINISM — two independent test runs; normalized diff
  must be empty

Dispatcher (cmd/nftban-installer/restore_decide.go):
- Classifies authority via uninstall.Classify (single source of truth)
- Probes prior-record via uninstall.Probe (PR-P2-1 hardened schema)
- Reduces uninstall.PriorRecordState + 365-day window → restore.PriorState
- Detects panel via detect.DetectPanel
- Assembles DecisionInput; calls restore.Decide (pure)
- Preflight errors (malformed record, Ambiguous invariant violation)
  short-circuit to ExitFatal WITHOUT emitting a lattice output —
  keeps the three-output space closed (seed §9)
- Transitions state file to terminal (Refused / IntentRequired) or
  non-terminal (Decided)
- history.json write skipped: IsApplyTerminal=false for all three
  states + main.go gate now also excludes cfg.mode=="restore" as
  belt-and-braces defense

No real-host mutation. Decision-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails
actions/actions/checkout 34e114876b0b11c390a56381ad16ebd13914f8d5 🟢 5.7
Details
CheckScoreReason
Maintained⚠️ 00 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 0
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Binary-Artifacts🟢 10no binaries found in the repo
Code-Review🟢 10all changesets reviewed
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
Packaging⚠️ -1packaging workflow not detected
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Pinned-Dependencies🟢 3dependency not pinned by hash detected -- score normalized to 3
Security-Policy🟢 9security policy file detected
Branch-Protection🟢 5branch protection is not maximal on development and all release branches
SAST🟢 8SAST tool detected but not run on all commits
actions/actions/setup-go d35c59abb061a4a6fb18e82ac0862c26744d6ab5 🟢 5.7
Details
CheckScoreReason
Maintained🟢 67 commit(s) and 1 issue activity found in the last 90 days -- score normalized to 6
Code-Review🟢 10all changesets reviewed
Binary-Artifacts🟢 10no binaries found in the repo
Packaging⚠️ -1packaging workflow not detected
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
Pinned-Dependencies⚠️ 0dependency not pinned by hash detected -- score normalized to 0
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
Branch-Protection⚠️ 0branch protection not enabled on development/release branches
SAST🟢 10SAST tool is run on all commits

Scanned Files

  • .github/workflows/ci-restore-canonization.yml

@itcmsgr itcmsgr merged commit 001e07f into main Apr 20, 2026
63 checks passed
@itcmsgr itcmsgr deleted the feat/v1.100-pr-24-restore-policy-engine branch April 20, 2026 13:20
itcmsgr added a commit that referenced this pull request Apr 27, 2026
#510)

Appends the PR-25 execution contract to internal/installer/restore/
contract.md as a new "PART II — PR-25 execution contract" section
(§§16-29). This is the doc-only first PR of the PR-25 two-PR split,
mirroring the PR-24 PR #493 → PR #494 pattern. The implementation
PR opens in a separate branch after this one merges.

Origin:
The contract is a faithful normalization of the locked Q1-Q5
design decisions (recorded 2026-04-20 during PR-24 freeze Day 0
via the §12 protocol: Stage 1 scope classification + Stage 2
five-field answer + LOCK/REVISE/REJECT review). The v0 staging
sheet (memory/project_pr25_contract_sheet_v0.md) was reviewed and
locked 2026-04-27 prior to opening this PR.

Locked rule applied: "Normalize, do not expand."

Every clause in §§16-29 traces back to a Q1-Q5 lock or to V1100
contract §8. No design decisions were made in this PR.

Section map:
- §16 Purpose
- §17 Scope (Option A) + 2 named invariants
- §18 TargetAuthority concretization (Q2)
- §19 StateRestoreDecided downstream meaning (Q3)
- §20 Panel-auto target consistency (Q4)
- §21 Post-restore verification split (Q5)
- §22 State terminals + exit codes (candidates)
- §23 Execution shape (V1100 §8 ordered)
- §24 Inputs PR-25 may consume
- §25 Forbidden behaviors (consolidated)
- §26 Cross-lock consistency
- §27 What this contract does NOT contain (intentional)
- §28 Merge-blocking real-host matrix (code phase)
- §29 Reviewer checklist (code phase)

Verified live code anchors (2026-04-27):
- knownFirewallType set {ufw, firewalld, iptables, csf} at
  internal/installer/uninstall/prior.go:278-284
- writeHistory gate excluding cfg.mode == "restore" at
  cmd/nftban-installer/main.go:132
- Exit-code constants ExitCommitted=0/ExitFatal=4/ExitRefused=5/
  ExitIntentRequired=6 at internal/installer/state/machine.go:149-155

§1-§15 (PR-24 decision contract) are untouched.

Out of scope (locked):
- No code in this PR. PR-25 implementation is the next PR
  (feat/v1.100-pr25-restore-execution).
- No expansion of Q1-Q5 lock content.
- PR-26 contract stays out of scope.

Lifecycle completion lane (PR-25..PR-30) remains explicitly OPEN
but is now mid-re-entry: contract is the first deliberate step.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant