Codex Project Takeover Guide¶
Read this document completely before touching any code. It is the
durable, repository-native handoff for continuing pfsense-mcp-server's
ADR-022 execution-authorization work. It is written to be self-contained:
a fresh agent with no memory of the Claude Code session that produced it
should be able to read this file plus the repository files it points to
and arrive at an accurate, independently-verified understanding of the
architecture, its security invariants, and what is safe to do next.
This document separates facts (verifiable against the repository right now), decisions (explicit owner choices that constrain future work), proposals (architecture written down but not authorized to build), and future ideas (named, not designed, not decided). Do not blur these categories when extending this document.
1. Authoritative repository state¶
| Field | Value |
|---|---|
| HANDOFF_SHA (last implementation commit) | 48a93862f95981c7c97b47ae94cc8467196b92c5 |
| Branch | main |
origin/main == local HEAD |
Confirmed equal at HANDOFF_SHA |
| Working tree | Clean (confirmed via git status --short, empty output) |
| Full pytest | 2288 passed, 42 skipped, 0 failed |
| ruff format --check | Clean (471 files formatted) |
| ruff check | Clean ("All checks passed!") |
mypy (mypy src/pfsense_mcp scripts lab witness_daemon) |
Clean — "Success: no issues found in 212 source files" |
mkdocs build --strict |
Clean — builds with only pre-existing, unrelated nav/relative-link INFO notices, no errors |
make quick |
11/11 stages PASSED |
make validate |
20/20 stages PASSED |
| Public MCP contract | 42 READ tools / 0 WRITE tools (public_contract: OK (42 tools)) |
| WRITE milestone status | 0 of 3 *_WRITE capabilities active; WriteEndpoints has zero entries; pfsense_mcp.tools.write is never imported |
| Last completed Phase E slice | Slice E2 — ExecutionCoordinator skeleton, through one-time authorization consumption only |
| First slice not started | Slice E3 — wiring store.create()/store.confirm()/executor.execute() behind the coordinator |
This document itself, once committed, may add one more commit on top of
HANDOFF_SHA (documentation-only). If so, that later commit is the
final takeover SHA; HANDOFF_SHA above remains the authoritative
last implementation commit. Codex should verify both independently
via git log --oneline -5 rather than trusting either SHA blindly — see
§16.
2. Project purpose and security posture¶
pfsense-mcp-server is a Model Context Protocol (MCP) server that lets
an AI client (Claude, or any MCP-speaking client) interact with a
pfSense firewall appliance. Its foundational, still-true architectural
decision (ADR-001, Accepted) is: the publicly exposed MCP tool
surface is READ-only. Every one of the 42 public tools performs a GET
against the pfSense REST API and nothing else — enforced structurally
(ADR-003's GET-only transport chokepoint) and by repository-wide
tests, not merely by convention.
Underneath that public surface, a substantial amount of inert WRITE-capable machinery exists — deliberately built ahead of activation, under a strict "architecture and tests first, activation last, always separately gated" discipline that spans more than twenty ADRs. This machinery falls into four layers, from lowest to highest:
- Tier 1 mutation machinery (
src/pfsense_mcp/tier1/): the sealed executor (executor.py,ADR-014), a closed recovery state machine (state_machine.py), a crash-recoveringRecoveryContractstore (store.py,contract.py,ADR-006), owner confirmation authority (ADR-012) and reconciliation authority (ADR-013), rate/blast-radius limits (ADR-015), and canonicalization/digest primitives (canonical.py) everything else in the security stack reuses rather than reinventing. - Authorization (
src/pfsense_mcp/security_*.py,ADR-021/022/023/024): a separate, higher-level layer answering "did a human operator actually review and sign off on this specific change," independent of and upstream from Tier 1's own execution machinery. This is the layer this session's work (Slices E1 and E2) extended. - Execution (Tier 1's
MutationExecutor): the only component authorized to make a non-GET network call, and only ever exactly one perexecute(), against an allow-listed endpoint (write_endpoints.py), throughwrite_api_client.py. - Public exposure (MCP tool registration): the layer that would let an AI client actually invoke any of the above. This layer remains completely untouched by every ADR discussed in this document. No WRITE tool has ever been registered. No milestone has been activated.
The critical fact for Codex to internalize: the presence of extensive,
well-tested WRITE-capable code in this repository does not mean WRITE
is enabled, reachable, or one step away from being enabled. Every
layer above is independently inert — MutationExecutor is never
constructed by production code; WriteEndpoints is an empty allow-list;
EngineerProfile.capabilities (the profile every runtime request
resolves against) is frozenset(); no MCP tool registration path leads
to any of it. Activation is gated behind "Milestone 9," a distinct,
separately-authorized, not-yet-made decision referenced throughout the
ADRs but not itself part of this document's scope.
3. Chronological architecture history (ADR map)¶
Full ADR index: docs/adr/README.md. Read the specific ADRs named below
in full — do not rely on this table alone for their content, only for
routing.
| ADR | Title | Status | What it decided | What remains open |
|---|---|---|---|---|
| 001–010 | READ-only architecture, typed boundaries, GET-only transport, capability profiles, inert Tier 0 WRITE, RecoveryContract philosophy, security-first schemas, fail-closed config, artifact encryption, key lifecycle | Accepted (006 "prerequisites incomplete") | Foundational Tier 0/1 architecture | 006's prerequisites — not this document's concern |
| 011 | Whole-store anti-rollback anchor | Backend decided (TPM-backed host witness), design-ready | The anchor mechanism itself | Not yet provisioned in any real deployment |
| 012 | Confirmation authority | Accepted | Owner-signed, per-mutation ConfirmationEvidence, PREPARED → EXECUTING gate |
— |
| 013 | Reconciliation authority | Accepted | How an ambiguous/AMBIGUOUS mutation outcome gets manually resolved |
— |
| 014 | Sealed executor interface | Accepted | MutationExecutor's exact shape and authority boundaries — the precedent Slice E2's coordinator design reuses directly |
— |
| 015 | Rate and blast-radius defaults | Accepted (mechanism); numeric defaults provisional | — | Exact numeric limits |
| 016 | Alias-candidate disposable-lab authorization | Accepted | — | — |
| 017 | Official guidance layer | Accepted, architecture + inert scaffolding only | — | No consumer wired |
| 018 | Version-aware Official Guidance resolution | Accepted, architecture/trust boundaries only | — | Nothing implemented |
| 019 | API Surface, Capability Discovery, and Extension Architecture | Accepted, vocabulary/evaluation only | — | Individual mechanisms separately gated |
| 020 | Milestone 0 — first WRITE capability candidate | Accepted, candidate naming only | Which capability would be first if ever activated | Implementation, lab run, allow-list population, activation all separately gated |
| 021 | Guided security-posture provisioning | Accepted, architecture/design only | The CapabilityPosture (read_only/write_protected) × AnchorAssurance (none/hardware_witness) two-axis model SecurityPosturePlan generation is built on |
No wizard, no posture, no WRITE, no fail-closed enforcement authorized |
| 022 | Execution-authorization boundary | Accepted, architecture/design only | The Plan → Authorize → Execute → Verify model and its own internal phase lettering A–H (see §6) |
No authorization/execution code, no WRITE tool, no schema change authorized by this ADR itself — later ADRs authorized specific slices under it |
| 023 | Authorization-verification boundary (ADR-022 Phase D) |
Proposed (architecture) — owner decisions made, Phase D implemented | PlanAuthorization/DeprovisionAuthorization data model, signing, three independent pure verifiers, durable one-time consumption store |
Not a full ADR-021/022-style acceptance — still "Proposed" |
| 024 | Execution-authorization coordination boundary (ADR-022 Phase E/F/G territory) |
Proposed (architecture) — Slice E1 and Slice E2 implemented | Freshness re-check primitive (E1) + ExecutionCoordinator skeleton through one-time consumption (E2) |
Slice E3 (wiring the coordinator to RecoveryContract/MutationExecutor) and everything past it — unauthorized |
Do not confuse "Accepted" with "implemented." ADR-022 is Accepted
as an architecture but authorizes zero code by itself — every line of
authorization/execution code that exists was authorized by a later,
narrower, explicitly-scoped instruction referencing it (ADR-023 for
Phase D, ADR-024 for Phase E). ADR-024 itself is still "Proposed,"
not "Accepted," even though two of its slices are implemented — its own
first line says so explicitly and this document does not change that.
4. Phase/slice status ledger¶
ADR-022's own phase lettering (context in ADR-022-execution-authorization-boundary.md):
A (acceptance) → B (PlanDigest) → C (PlanAuthorization/
DeprovisionAuthorization data model + signing) → D (pure
verification + consumption tracking) → E (freshness/precondition
engine only) → F (execution coordinator for CONFIGURATION-class
mechanism only) → G (execution coordinator around existing
RecoveryContract/MutationExecutor) → H (MCP WRITE exposure,
gated on Milestone 9).
ADR-024's own "Naming note" explains it analyzes the combined
E/F/G territory as one architecture document, but recommends —and this
session followed — building it as separate, narrower, independently
authorized slices (its own vocabulary, distinct from ADR-022's
phase letters): Slice 1 = E1, Slice 2 = E2, Slice 3 = E3.
| Phase/Slice | Objective | Resulting invariant | Modules introduced | Key tests | Deliberately untouched |
|---|---|---|---|---|---|
| B | Deterministic plan identity | compute_plan_digest()/verify_plan_digest() produce an exact, canonical identity over a SecurityPosturePlan |
security_plan_digest.py |
tests/test_security_plan_digest*.py |
Signing, verification, execution |
| C | Signed authorization artifacts | A PlanAuthorization/DeprovisionAuthorization is a frozen, fully self-validating dataclass; signing happens only on the operator side |
security_authorization.py |
tests/test_security_authorization*.py |
Verification, consumption, execution |
| D | Independent pure verification + one-time consumption | Signature/expiry/scope are three independent checks; an authorization_id can be consumed at most once, durably, across restarts and concurrent races |
security_authorization_verifier.py, tier1/authorization_consumption_store.py |
tests/test_security_authorization_verifier*.py, tests/tier1/test_authorization_consumption_store*.py |
Composition of the four primitives with each other; execution wiring |
| E1 | Freshness re-check | "A previously authorized plan is fresh only when a newly discovered posture, run through the same deterministic planner and compute_plan_digest(), reproduces the exact authorized plan_digest" — evidence_fingerprint alone is explicitly insufficient |
security_plan_freshness.py |
tests/test_security_plan_freshness*.py |
Consumption, coordinator, execution |
| E2 (current HEAD) | Coordinator skeleton through consumption | "An execution attempt may reach the consumed state only after signature validity, expiry/currentness, exact plan-digest + authorized-step membership, and full freshness re-check all succeed, in that order" | tier1/execution_coordinator.py |
tests/tier1/test_execution_coordinator*.py |
RecoveryContract creation, MutationExecutor, state_machine.py, store.create()/confirm() |
| E3 (not started) | Wire store.create()/confirm()/executor.execute() behind the coordinator |
(not yet established — this is exactly what E3 would establish) | Extends execution_coordinator.py only |
Not written | executor.py/state_machine.py's own public behavior; still no MCP construction site |
| F/G remainder, H | Full execution path; MCP WRITE exposure | — | — | — | Everything, until Milestone 9 |
5. Security invariants Codex MUST preserve¶
These are architectural guarantees enforced by tests today, not aspirations. Treat a test that encodes one of these as load-bearing — see §12 for the explicit warning about not "fixing" a failing security test by weakening it.
- Fail-closed, everywhere. An indeterminate outcome (I/O error, ambiguous read, malformed stored state, unexpected exception) is always treated as "not authorized to proceed." Never "proceed anyway." This applies uniformly across discovery, planning, digesting, verification, freshness, and consumption.
- Canonicalization has exactly one implementation.
tier1/canonical.py'scanonical_json()/digest_value()/DigestPurposedomain separation is the only hashing/canonicalization path in the entire security stack. No module — including the new coordinator — defines a second one; this is directly tested (test_module_defines_no_second_digest_or_canonicalization_function-style checks exist for the freshness module and should exist for any future module that touches digests). PlanDigesthas exactly one implementation.security_plan_digest.py'scompute_plan_digest()/verify_plan_digest()are the sole authority for "does this plan match that digest." Nothing recomputes a digest a different way.PlanAuthorizationis a signed artifact, not a claim. Aproofis only meaningful once verified against aPinnedAuthoritySet(ADR-012's precedent, reused). Signer identity is pinned, not inferred; an unknown or inactiveauthority_idfails closed.- Expiry is exact and independent.
plan_authorization_is_current()checksnow < expires_at, exclusive boundary, and deliberately does not validateissued_at-side timing — this is documented, intentional behavior, not a gap to "fix." - Plan binding is exact, not fuzzy.
plan_authorization_authorizes_step()requires exactplan_digestequality (viahmac.compare_digest) and exactstep_idmembership. No wildcard, prefix, or partial matching exists anywhere, by design and by test. - Freshness is exact
plan_digestrecomputation, never a proxy.evidence_fingerprintcomparison is not sufficient — it excludes thestepslist, which participates inplan_digestbut not inevidence_fingerprint's six fields. This was a genuine finding of this session's work (ADR-024's "Freshness/precondition model," E4), now enforced bysecurity_plan_freshness.pynever even readingevidence_fingerprint. - Consumption is one-time, durable, and atomic.
try_consume()proves "at most one caller ever observesTruefor a givenauthorization_id," across restarts and concurrent races (tested with a real 8-thread barrier race in both the store's own tests and the new coordinator's tests). The accepted semantic is "one attempt to create aRecoveryContract," not "one execution attempt" and explicitly not "one successful execution" (see §7). RecoveryContract/ConfirmationEvidence/MutationExecutorboundary is untouched and must stay that way.MutationExecutorremains completely authorization-unaware — it operates purely on an already-PREPARED, already-confirmedRecoveryContractloaded bycontract_id; nothing in its signature references a Plan, digest, or authority. This is a hard requirement (ADR-024's G2), not a convenience default — seeADR-024's "Rejected designs" for why folding authorization into the executor was considered and refused.tier1/state_machine.py's closed transition table is unaffected. No new state, no new transition, for any authorization purpose.- Isolation boundaries are architectural controls. See §5 of this document's own philosophy note and §12 below — do not relax an isolation test to make an import "more convenient."
- Public WRITE remains fully prohibited until a separate, explicit Milestone 9 activation decision, itself gated behind its own future authorization, not part of anything in this document.
- Target-identity status is an open, intentionally unresolved gap — not a bug, not an oversight. See §9.
6. Component map¶
| Path | Responsibility | Allowed side effects | Major dependencies | Security significance | Codex should normally... |
|---|---|---|---|---|---|
src/pfsense_mcp/tier1/canonical.py |
Canonical JSON + purpose-separated digesting | None (pure) | stdlib only | Sole hashing/canonicalization authority | Never modify without extreme care and full re-review of every dependent module |
src/pfsense_mcp/security_discovery.py |
Read-only discovery of live capability posture + anchor assurance | One read-only I/O boundary (discover_security_posture()) |
httpx, TPM witness client |
Feeds planning; must fail closed on I/O error | Not modify without a separately authorized slice |
src/pfsense_mcp/security_plan.py |
Deterministic SecurityPosturePlan generation from discovery + a requested target |
None (pure over its input) | security_discovery |
Enforces ADR-021's two-axis validity constraint |
Not modify |
src/pfsense_mcp/security_plan_digest.py |
compute_plan_digest()/verify_plan_digest() |
None (pure) | tier1.canonical |
Sole PlanDigest authority |
Not modify |
src/pfsense_mcp/security_authorization.py |
PlanAuthorization/DeprovisionAuthorization data model, payload construction, operator-side signing |
None (pure) | tier1.canonical (digest only) |
Signed-artifact schema | Not modify without a schema-change-level authorization |
src/pfsense_mcp/security_authorization_verifier.py |
Three independent pure verifiers (signature, expiry, scope) | None (pure) | security_authorization, tier1.ed25519_authority |
Phase D's verification core | Not modify; compose, don't reimplement |
src/pfsense_mcp/security_plan_freshness.py |
Freshness re-check (plan_authorization_is_fresh()) |
One read-only I/O boundary (delegates to security_discovery) |
security_discovery, security_plan, security_plan_digest |
Slice E1's core invariant | Not modify |
src/pfsense_mcp/tier1/authorization_consumption_store.py |
Durable one-time authorization_id consumption |
Writes exactly one row per successful consumption | tier1.canonical, tier1.errors, SQLite |
Replay-prevention authority | Not modify |
src/pfsense_mcp/tier1/execution_coordinator.py (NEW, Slice E2) |
Composes the five gates above in fixed order; the only component permitted to call try_consume() in the authorization flow |
One state-changing call (try_consume()) |
security_authorization, security_authorization_verifier, security_discovery, security_plan_freshness, tier1.authorization_consumption_store, tier1.ed25519_authority |
This slice's entire deliverable | Extend only under an explicitly authorized future slice (E3); do not add store/executor calls without that authorization |
src/pfsense_mcp/tier1/store.py |
SqliteRecoveryContractStore — crash-recovering, compare-and-set RecoveryContract persistence |
Writes/transitions contract rows | tier1.canonical, tier1.contract, tier1.crypto |
ADR-006/ADR-009/ADR-010 |
Not modify without a separately authorized slice |
src/pfsense_mcp/tier1/contract.py |
RecoveryContract, ProtectedArtifact |
None directly (data + verification methods) | tier1.canonical |
Binding-chain target/intent verification | Not modify |
src/pfsense_mcp/tier1/state_machine.py |
Closed RecoveryState transition table |
None (pure) | tier1.errors |
Cannot be bypassed or extended informally | Not modify without a separately authorized slice |
src/pfsense_mcp/tier1/executor.py |
MutationExecutor — the sole component authorized to send a non-GET request |
Exactly one non-GET send per execute() |
write_api_client, pfsense_client, tier1.store, others |
ADR-014's sealed executor |
Not modify without a separately authorized slice; must remain authorization-unaware (G2) |
src/pfsense_mcp/write_api_client.py |
send_for_tier1() — the one chokepoint for any non-GET call |
The network call itself | HTTP transport | ADR-003's GET-only enforcement boundary |
Not modify |
src/pfsense_mcp/write_endpoints.py |
The sole mutation allow-list | None (class-level data) | — | Currently empty — 0 endpoints active | Not modify without an explicit, separately authorized allow-list-population decision |
MCP registration/exposure layer (src/pfsense_mcp/tools/, application.py, factory.py) |
Registers the 42 public READ tools | Tool registration only | — | No WRITE tool registered anywhere; tools/write never imported |
Never register a WRITE tool without Milestone 9 authorization |
7. Isolation architecture¶
This project enforces dependency direction with AST-based structural
tests, not just code review. tests/tier1/test_isolation.py is the
central control:
test_tier1_is_not_imported_outside_its_inert_package— proves no production module outsidetier1/importspfsense_mcp.tier1, except a small, explicit, individually-justifiedexemptset:tier1_anchor_check.py,security_discovery.py,security_plan_digest.py,security_authorization.py,security_authorization_verifier.py. Each entry has its own paragraph explaining why it is safe (what it imports fromtier1, and what it structurally cannot do). This is the "outward-in" direction.test_tier1_domain_has_no_transport_or_tool_registration_dependency— proves everytier1/*.pymodule is forbidden from importingrest_api_client/transport/tools(universal), and forbidden from importingwrite_api_client/pfsense_clientexceptexecutor.py(the one sealed exception,ADR-014's Invariant I1). This is the "inward-out" direction, i.e. tier1 reaching toward pfSense.
Slice E2 added a third direction, which ADR-024's own "E1 —
Coordinator ownership and placement" section names explicitly: a
tier1/*.py module (execution_coordinator.py) reaching into the
security_ family (security_authorization,
security_authorization_verifier, security_discovery,
security_plan_freshness) — the first time this has happened in this
codebase. This was not structurally forbidden by either existing
test above (neither test's forbidden-root list names the security_
family at all), so no modification to the shared tests was needed.
Instead, a dedicated tests/tier1/test_execution_coordinator_isolation.py
locks down exactly this module's reviewed import set — mirroring the
established per-module isolation-test pattern already used for
security_authorization_verifier.py/security_plan_freshness.py
(tests/test_security_authorization_verifier_isolation.py,
tests/test_security_plan_freshness_isolation.py).
Four pre-existing "no production module imports this yet" tests
(one each in security_authorization, security_authorization_verifier,
security_plan_freshness, authorization_consumption_store's own
isolation test files) were each narrowly extended, in this session, to
name execution_coordinator.py as their one reviewed consumer — the
same pattern already established when security_authorization_verifier.py
first became security_authorization.py's own reviewed consumer. If a
future slice (E3) needs a new consumer of any of these primitives, or a
new outward-reaching import, follow this exact pattern: name the file
explicitly, explain why in a docstring/comment, never loosen the check
to a wildcard or a broad allowance.
Expected dependency direction, current state: security_* modules
depend on tier1.canonical/tier1.ed25519_authority only (never on
tier1's stateful machinery). tier1/execution_coordinator.py depends
on the security_* family plus its own tier1 siblings
(authorization_consumption_store, ed25519_authority, errors) —
and, as of this handoff, nothing else: it does not yet import
executor.py, store.py, contract.py, or state_machine.py. That is
exactly what Slice E3 would add, under its own future authorization.
Codex must treat every isolation test as an architectural control. See §12 for the explicit warning against "fixing" one by weakening it.
8. Authorization lifecycle — implemented vs. proposed¶
Source of truth: ADR-024's "Exact proposed verification/execution
ordering (E2)" section, reproduced here with implementation status
annotated. (ADR-024's own numbering is steps 1–12; this table uses the
same numbers.)
| # | Step | Status at HANDOFF_SHA |
|---|---|---|
| 1 | Capability active (ADR-004 profile check) |
Existing, unaffected by any authorization-boundary work |
| 2 | Endpoint allow-listed (WriteEndpoints, ADR-005) |
Existing, unaffected; allow-list is empty |
| 3 | verify_plan_authorization_signature() |
Implemented (Phase D) and composed by the coordinator (Slice E2) |
| 4 | plan_authorization_is_current() |
Implemented (Phase D) and composed (Slice E2) |
| 5 | plan_authorization_authorizes_step() |
Implemented (Phase D) and composed (Slice E2) |
| 6 | Freshness re-check | Implemented (Slice E1) and composed (Slice E2) |
| 7 | Anchor assurance appropriate for authz.risk_class |
Not separately implemented — ADR-024 itself documents this as structurally subsumed by step 6 (an invalid target combination never reaches a valid fresh plan; any anchor regression changes the fresh plan's own digest), and Slice E2's own docstring/ADR update records this reasoning explicitly rather than adding a redundant check |
| 8 | Authorization consumption (try_consume()) |
Implemented (Phase D primitive) and wired as the coordinator's last gate (Slice E2) — this is where the current implementation boundary stops |
| 9 | store.create() |
Not implemented, not wired. This is Slice E3's first task. |
| 10 | store.confirm() with ConfirmationEvidence |
Existing (ADR-012), not yet reachable from the authorization path |
| 11 | executor.execute(contract_id, ...) |
Existing (ADR-014), not yet reachable from the authorization path |
| 12 | Post-condition verification, audit write | Existing, not yet reachable from the authorization path |
The implementation boundary is unmistakable: steps 1–8 are fully
implemented and composed by ExecutionCoordinator.authorize_and_consume().
Steps 9–12 do not exist in the authorization path at all yet —
ExecutionCoordinator does not import store.py, contract.py, or
executor.py. A successful authorize_and_consume() call today means
only "every pre-execution gate passed and the authorization is now
durably consumed" — nothing about a RecoveryContract, confirmation,
or an actual mutation has happened, and nothing in the current code
makes that happen.
9. Consumption semantics and crash behavior¶
Owner-decided, fixed semantic: "one authorization permits one attempt
to create a RecoveryContract." Not "one execution attempt." Not "one
successful execution."
- "One successful execution only" was explicitly rejected
(
ADR-024, "Consumption semantics," E3). If consumption happened only after a successfulexecutor.execute(), the authorization would remain valid and reusable for the entire window between "checks passed" and "execution confirmed" — during which it could gate multiple concurrentRecoveryContracts, directly reopening the replay exposure Phase D exists to close. - "One attempt" is precise and testable:
try_consume()succeeding means "thisauthorization_idwill never again gate a contract-creation attempt," full stop — regardless of what happens to that (not-yet-existing) contract afterward. - Freshness failure does not consume. A
STALEoutcome at the freshness gate is explicitly not a security anomaly (ADR-022's ownSTALE-vs-anomaly classification, reused unchanged) — re-planning after a transient staleness produces a differentplan_digestanyway, so non-consumption costs nothing and preserves the original authorization for a transient-flicker case. - Acknowledged, documented gap: the 8→9 crash window. If a crash
occurs between successful
try_consume()(step 8) andstore.create()(step 9, not yet implemented), the authorization is permanently consumed with no correspondingRecoveryContractever created. This is real, not hypothetical —try_consume()and a futurestore.create()are two separate atomic operations against two separate stores; no shared transaction exists or is proposed.ADR-024's own assessment: this is an accepted, explicitly-named v1 safety-over-availability tradeoff, not a silently-accepted gap. An operator whose authorization is burned by a crash in this window can simply re-review and re-sign a new one for the same still-valid plan. - Two-phase claim/commit is named, not designed, not decided, and
Codex must not invent it.
ADR-024's "Recommended, explicitly deferred future enhancement" names a hypotheticalclaim(authorization_id) -> ClaimToken/commit(claim_token)extension purely for future reference — it would need its own design pass, its own adversarial review, and its own owner authorization. Do not build any part of it opportunistically while working on E3 or anything else.
10. Freshness (Slice E1) — precise recap¶
- Fresh discovery source: the same
discover_security_posture()path already used for planning — no new evidence source. - Deterministic plan regeneration:
generate_security_posture_plan()is re-run with the same target parameters (target_capability_posture/target_anchor_assurance) the original plan used. compute_plan_digest()/verify_plan_digest()ownership: these remainsecurity_plan_digest.py's exclusive responsibility;security_plan_freshness.pycalls them, never reimplements them (directly proven bytest_module_defines_no_second_digest_or_canonicalization_function).- Exact equality, always: freshness is
verify_plan_digest(fresh_plan, expected_plan_digest)— full byte-level canonical equality, never an approximate or partial comparison. - Why
evidence_fingerprintis insufficient: it excludes thestepslist. Ifsecurity_plan.py's step-generation logic ever changed between authorization time and execution time,evidence_fingerprintwould show no difference even thoughplan_digestwould — proven directly bytest_unchanged_evidence_fingerprint_but_added_step_is_staleand siblings intests/test_security_plan_freshness.py. - Failure behavior: malformed/wrong-type
expected_plan_digestreturnsFalse(not an exception); an unexpected discovery/plan- generation/digest-generation failure raises a sanitizedPlanFreshnessError— never silently returnsTrue. - No side effects:
plan_authorization_is_fresh()performs zero authorization-consumption, zeroMutationExecutor, zero state-machine interaction — proven bytests/test_security_plan_freshness_isolation.py.
Reference tests: tests/test_security_plan_freshness.py (regression/
adversarial), tests/test_security_plan_freshness_isolation.py (AST-based).
11. Target identity gap — read carefully before any future slice touches it¶
This is the single most important open architectural question in the authorization boundary, and it is intentionally unresolved. Do not close it unilaterally.
Facts (verified against shipped code, ADR-024's "target_identity_digest
design," E5):
- Ordinary
PlanAuthorizationhas notarget_identity_digest-shaped field at all in its already-shipped, already-pushed schema. Adding one would be a schema change to an already-shipped artifact type. DeprovisionAuthorizationhas a related, already-accepted field (target_identity_digest), but its deferral is already decided byADR-022itself — no code path anywhere constructs aDeprovisionAuthorizationat all, because no destructive execution mechanism exists yet. This is a separate question from the one below and must not be conflated with it.netgate_id(src/pfsense_mcp/models/system.py'sSystemStatus) andpfhostid(src/pfsense_mcp/models/system_ha_sync.py'sSystemHaSync) are real, already-modeled, pfSense-native, per-installation identifiers — genuine candidate signals for a future appliance-identity binding.- Both are deliberately null by default
(
include_identifying_metadata=False) in every existing caller — this codebase already treats them as privacy-sensitive, hidden-unless- explicitly-requested data, a deliberate, pre-existing convention, not something this session invented. - No target-binding mechanism has been authorized. Nothing in the
currently-accepted authorization chain cryptographically distinguishes
"this plan, reviewed and authorized for Appliance A" from "an
identical plan, by coincidence, on Appliance B" — a real, narrow gap,
bounded by the fact that this project's entire architecture currently
assumes exactly one pfSense appliance per deployment
(
PfSenseConfig/PFSENSE_API_URLis a single, required, env-var- driven target; there is no multi-appliance or multi-tenant concept anywhere inconfig.py, the MCP transport, or the tool registry). - The desired architectural direction, per explicit owner instruction during this session, is that the architecture must remain correct for a future multi-appliance deployment without authorization becoming implicitly portable between appliances — i.e., closing this gap correctly matters, but must not be done by inventing a placeholder.
Unresolved prerequisites, named by ADR-024, not resolved by
anything in this repository yet:
- An explicit owner/product decision on whether this project's deployment model is intended to remain permanently single-appliance- per-process (if so, this gap may be judged acceptably low-value to close at all).
- If closing it is judged worthwhile: an explicit owner decision on
whether to override the existing
include_identifying_metadataprivacy default for this one purpose. - A null-handling design for deployments with neither
netgate_idnorpfhostidavailable. - A schema-placement decision —
ADR-024recommends against folding this intoplan_digestitself (would require a schema-version bump to an already-shipped, already-tested Phase B primitive), in favor of a new, separate, additive field/digest at whatever future layer consumes it. - (Named by this document, not
ADR-024, as a natural consequence of #2–#4): lifecycle behavior across backup/restore/clone/HA/migration scenarios, if and when a concrete design is proposed — pfSense appliances can be cloned or restored from backup, which interacts directly with any identity-binding scheme.
Codex MUST STOP and report, rather than inventing a substitute, if any
future slice (including E3) turns out to require target-identity
binding for correctness. ADR-024's own "Explicit stop conditions for
each future slice" names this directly: "Any slice that discovers
target_identity_digest is load-bearing for correctness (not merely
defense-in-depth) — stop, that reopens Question B and needs its own
owner decision first."
12. Owner decisions (fixed, not proposals — do not re-litigate)¶
These were explicit choices made by the project owner during this
session's Phase E work, each narrowing an otherwise-open architecture
question. Distinguish these from ADR-024's own proposals (§ sections
above) — a proposal is something ADR-024 recommends; an owner decision
is something already chosen and binding.
- The architecture must remain correct for a future multi-appliance deployment without authorization becoming implicitly portable between appliances (§11).
- Do not override the identifying-metadata privacy default, and do not
add
netgate_id/pfhostidtoPlanAuthorizationor wire them into runtime authorization, without a separate, explicit future decision. - The coordinator's placement is
src/pfsense_mcp/tier1/execution_coordinator.py— tier1-native, sibling toexecutor.py— a fixed placement decision, not merelyADR-024's recommendation. MutationExecutorremains authorization-unaware, permanently, not merely for the current slice — folding authorization into the executor was considered and rejected, not merely deferred.- Two-phase authorization consumption (claim/commit) is deferred; the v1 "one attempt to create a RecoveryContract" semantic, with its acknowledged 8→9 crash-window tradeoff, is accepted as-is for now.
DeprovisionAuthorizationverification remains deferred — no parallel verifier, no shared machinery built ahead of an actual destructive-execution mechanism existing.- The appliance
target_identity_digestgap (Question B in §11) remains explicitly deferred — no substitute, no placeholder, no weaker proxy field, ever, without a new owner decision resolving the prerequisites in §11. - Public MCP remains READ-only until a separate, explicit Milestone 9
activation decision — nothing in the E1/E2 work changes this, and
nothing in a hypothetical E3 would either, since E3's own scope (per
ADR-024's "Explicit stop conditions") excludes any MCP construction site.
13. Threat model / known attack classes¶
Full authoritative threat model: docs/THREAT_MODEL.md (read in full —
it frames this project's local-stdio trust model: defends against a
malicious/compromised MCP client or AI model interacting with an
already-deployed, already-reviewed server; explicitly does not
defend against a malicious contributor modifying the server's own
source). ADR-024's own consolidated "Threat analysis (consolidated
matrix)" table is the authoritative disposition list for the
authorization-boundary-specific threats below — read it in full rather
than relying solely on this summary.
| Threat class | Disposition at HANDOFF_SHA |
|---|---|
| Authorization forgery | Closed — PinnedAuthoritySet, ADR-012 precedent, reused unchanged |
| Wrong signer / signer downgrade | Closed — unknown/inactive authority fails closed; algorithm checked before any verification attempt |
| Replay | Closed — try_consume()'s atomic insert-once, now wired as the coordinator's last gate (Slice E2) |
| Expired authorization | Closed — plan_authorization_is_current(), independent check |
| Plan / step substitution | Closed — exact plan_digest match + exact authorized_step_ids membership, no wildcard matching |
| Target substitution (appliance-level) | Open, intentionally — see §11 |
| Target substitution (resource-level, within one appliance) | Closed at the RecoveryContract layer (existing target_fingerprint drift detection) — not yet reachable from the authorization path since E3 is not implemented |
| Stale target state | Closed — Slice E1's freshness re-check, now composed by the coordinator (Slice E2) |
| TOCTOU (time-of-check/time-of-use) | Bounded per-transition; the 8→9 crash window (§9) is the one acknowledged, documented gap |
| Double execution / concurrent execution | Closed at the consumption layer (proven via an 8-thread race in both the store's own tests and the coordinator's tests); the RecoveryContract-layer CAS defense is existing but not yet reachable from this path |
| Consumption DB tampering / rollback / copy | Closed for tampering (fails closed, tested); rollback/copy is an inherited, acknowledged limitation shared with every other Tier 1 store — not a gap introduced by this work |
| Direct executor bypass | Construction-site/process-review-level guarantee only, not language-level — explicitly documented as such in ADR-024's E7 and in execution_coordinator.py's own module docstring |
| State-machine bypass | Not possible — closed transition table unaffected |
| Canonicalization / digest-domain disagreement | Closed — single shared tier1.canonical primitive, DigestPurpose domain separation, reused everywhere |
14. Test map¶
- Security-semantics tests (encode the actual invariants, not just
code coverage):
tests/test_security_authorization*.py,tests/test_security_plan_freshness.py,tests/tier1/test_authorization_consumption_store.py,tests/tier1/test_execution_coordinator.py. - Isolation/AST tests (structural, dependency-direction proofs, not
ordinary unit tests):
tests/tier1/test_isolation.py(the two shared, central tests), plus one dedicated file per security-boundary module —tests/test_security_authorization_isolation.py,tests/test_security_authorization_verifier_isolation.py,tests/test_security_plan_freshness_isolation.py,tests/tier1/test_authorization_consumption_store_isolation.py,tests/tier1/test_execution_coordinator_isolation.py. - Adversarial tests: embedded throughout the regression files above (forged signatures, tampered fields, wrong scopes, malformed input, algorithm downgrade attempts, "same step ID in a different plan," etc.) — not a separate directory.
- Concurrency tests:
threading.Barrier-based races proving exactly-one-success, intests/tier1/test_authorization_consumption_store.py(test_concurrent_double_consumption_yields_exactly_one_success) andtests/tier1/test_execution_coordinator.py(test_concurrent_attempts_yield_exactly_one_success). - Persistence/tamper tests:
tests/tier1/test_authorization_consumption_store.py's tampered-row/tampered-timestamp/malformed-schema/database-failure tests — the pattern any future persistence-adjacent test should follow. - Coordinator tests (Slice E2, new):
tests/tier1/test_execution_coordinator.py(38 tests: happy path, each gate's independent denial + non-consumption proof, exact ordering proofs via monkeypatched "must not be called" spies, concurrency, cross-cutting invariants) andtests/tier1/test_execution_coordinator_isolation.py(10 AST-based structural tests).
Explicit warning, repeated because it matters: a failing security or isolation test is not authorization to weaken the test. Assume you violated an invariant first, and re-read this document's §5 and the relevant ADR before considering any change to a test's assertions.
15. Validation commands (repository-standard, not invented)¶
Run from the repository root with .venv activated (source .venv/bin/activate).
# Targeted (fast, run first while iterating)
python -m pytest tests/tier1/test_execution_coordinator.py tests/tier1/test_execution_coordinator_isolation.py -q
# Full suite
python -m pytest -q
# Formatting / lint
ruff format --check .
ruff check .
# Types
mypy src/pfsense_mcp scripts lab witness_daemon
# Docs
mkdocs build --strict
# Repository-defined composite gates (see Makefile)
make quick # 11-stage fast gate: format/lint/type/test + GET-only + write-inactivity checks
make validate # 20-stage full gate: everything in quick + bandit + fixture/query-param safety +
# public-contract snapshot + doc consistency + git report
make validate's stage 18 ("Public MCP contract snapshot") is the
authoritative, automated check for "did I accidentally expose or grow
the WRITE surface" — it must always report public_contract: OK (42 tools)
with 0 WRITE. Treat any change to that number as a stop-and-report
event, not something to silently accept.
16. Current deferred-work register¶
| Item | Why deferred |
|---|---|
Target-identity binding for ordinary PlanAuthorization |
Requires an owner product decision (single- vs. multi-appliance), a privacy-default override decision, a null-handling design, and a schema-placement decision — none made yet (§11) |
DeprovisionAuthorization verification |
ADR-022 itself defers it; no destructive execution mechanism exists yet to consume a verified one; building shared machinery now would be speculative |
Slice E3 (coordinator → RecoveryContract/MutationExecutor wiring) |
Not yet authorized — see §17 for what it would need |
| Two-phase claim/commit consumption | Named for future reference only, not designed, not decided — would need its own design pass and owner authorization |
| Public MCP WRITE exposure (Phase H) | Gated on Milestone 9, an entirely separate, not-yet-made activation decision |
| WRITE milestone activation generally | Same — 0 of 3 *_WRITE capabilities may become active without a dedicated future authorization |
Guided security-posture provisioning wizard (ADR-021) |
Accepted architecture only — no wizard, posture enforcement, or fail-closed runtime behavior authorized yet |
Official/version-aware guidance layer consumption (ADR-017/018) |
Accepted architecture + inert scaffolding only — no consumer wired |
17. Next recommended slice (planning only — NOT authorization to implement)¶
Per HANDOFF_SHA, the smallest safe next step is ADR-024's Slice
3, exactly as that document specifies it (docs/adr/ADR-024-execution-authorization-coordination.md,
"Implementation slices for a future authorized coding phase" → "Slice 3
— wire store.create()/confirm()/executor.execute() behind the
coordinator"):
- Objective: extend
ExecutionCoordinatorso that a successful Slice E2 outcome (consumption) is followed bystore.create(),store.confirm()with realConfirmationEvidence, andexecutor.execute(contract_id, ...)— completing ordering steps 9–12 from §8's table. - Invariant to establish: "the full authorization-to-execution chain is provably correct end-to-end against synthetic dependencies, still fully unreachable from any production entry point."
- Expected files:
tier1/execution_coordinator.py(extend only). - Forbidden files/behavior:
tier1/executor.py,tier1/state_machine.py(their existing public behavior must not change — this slice calls their existing public APIs, never modifies them); no MCP construction site; no target-identity mechanism unless it turns out to be load-bearing (§11), in which case STOP. - Major tests required: full happy path against synthetic/mock
stores and a synthetic adapter (mirroring
test_executor.py's own convention); a crash-simulation test between consumption and contract creation (proving the acknowledged 8→9 gap's actual behavior, not just its written description); a concurrency test proving two coordinator calls racing the same authorization yield exactly oneRecoveryContract. - Major STOP conditions: any requirement to modify
MutationExecutor's orstate_machine.py's existing public behavior; any requirement to construct the coordinator fromApplication/factory.py/the tool registry before Milestone 9's own separate activation decision; any discovery thattarget_identity_digestis load-bearing for correctness.
This section is planning information only. Do not begin Slice E3 during bootstrap (§18) — a separate, explicit owner authorization, matching the narrow, scoped style of every prior slice in this project, is required first.
18. Codex bootstrap protocol¶
On first takeover, Codex must, in order:
git fetch origin.- Verify the exact current
HEAD/origin/mainSHA — do not assume it still equalsHANDOFF_SHAabove; a documentation-only commit may have been added after it (see §1's note). Record whatever SHA is actually current. - Confirm the working tree is clean (
git status --short, expect empty output). - Read
docs/CODEX_TAKEOVER.md(this document) completely. - Read the files listed in §19's mandatory reading order, in that order.
- Independently verify important claims against repository code and
tests — do not take this document's assertions on faith where they
are checkable (e.g., re-run
make validate's public-contract stage yourself; grep fortarget_identity_digest/netgate_id/pfhostidyourself; confirmexecution_coordinator.pydoes not importexecutor/store/contract/state_machineyourself). - Run an appropriate baseline validation (§15) and record actual results.
- Report understanding of: current architecture; security invariants (§5); current implementation boundary (§8); unresolved gaps (especially §11); next proposed slice (§17).
- STOP. Do not modify code during this bootstrap. A separate owner authorization, in the same narrow, explicitly-scoped style used for every slice in this project's history, is required before any implementation begins — including Slice E3.
19. Mandatory reading order for Codex¶
Read each of these completely, not by snippet, in this order. This list is curated to minimize context use while preserving architectural understanding — do not read the entire repository.
docs/CODEX_TAKEOVER.md(this document)docs/adr/README.md(ADR index)docs/adr/ADR-022-execution-authorization-boundary.mddocs/adr/ADR-023-authorization-verification-boundary.mddocs/adr/ADR-024-execution-authorization-coordination.mddocs/adr/ADR-021-security-posture-provisioning.mddocs/EXECUTION_AUTHORIZATION_BOUNDARY.mddocs/tier1/specs/sealed_executor.mddocs/THREAT_MODEL.mdsrc/pfsense_mcp/security_discovery.pysrc/pfsense_mcp/security_plan.pysrc/pfsense_mcp/security_plan_digest.pysrc/pfsense_mcp/security_authorization.pysrc/pfsense_mcp/security_authorization_verifier.pysrc/pfsense_mcp/security_plan_freshness.pysrc/pfsense_mcp/tier1/authorization_consumption_store.pysrc/pfsense_mcp/tier1/execution_coordinator.py(the newest module)src/pfsense_mcp/tier1/executor.pysrc/pfsense_mcp/tier1/state_machine.pytests/tier1/test_isolation.pytests/tier1/test_execution_coordinator.pyandtests/tier1/test_execution_coordinator_isolation.py
Skim rather than fully read, only if time-constrained:
src/pfsense_mcp/tier1/store.py, src/pfsense_mcp/tier1/contract.py
(useful context for Slice E3 planning, not required for understanding
the current boundary).
20. Historical traps / non-obvious findings¶
Things a fresh agent could easily get wrong, learned the hard way during this project's history:
- The canonicalization module lives at
src/pfsense_mcp/tier1/canonical.py, not any path resemblingsecurity_canonical.py— an earlier task instruction in this project's history named a non-existent path and had to be corrected against the actual repository. evidence_fingerprintdoes not bind the complete plan. It excludes thestepslist. Never treat it as a substitute for fullplan_digestrecomputation, for freshness or for anything else.- Ordinary
PlanAuthorizationhas no target-identity field at all — this is easy to assume exists (sinceDeprovisionAuthorizationhas a related one) and is not the same question. See §11. - The presence of internal WRITE-related code does not mean MCP WRITE
is exposed. This project deliberately builds inert machinery years
ahead of activation. Always check the actual MCP tool registry and
make validate's public-contract stage, never infer exposure from the existence oftier1/executor.pyor similar. - Python-level bypass resistance is a construction-site/AST-test-time
guarantee, not a language-level one.
ADR-024's E7 section states this explicitly and Codex should not claim stronger bypass resistance than actually exists in any future documentation or code comments. - Consumption semantics prioritize replay safety over retry ergonomics, deliberately. The 8→9 crash window (§9) is a known, accepted tradeoff — do not "fix" it by weakening consumption atomicity or by silently building two-phase consumption without a fresh, explicit authorization for exactly that.
plan_authorization_is_current()deliberately does not checkissued_at. This is intentional, documented behavior in that module's own docstring — not a gap.- The coordinator's
requested_plan_digest/requested_step_idparameters are deliberately separate fromauthz.plan_digest/the step being checked. Passingauthz.plan_digestback as its own comparison target would be a tautology; the real check is "does what the caller claims to be requesting match whatauthzactually authorizes" — a confused-deputy defense, not redundant plumbing. Any future slice that "simplifies" this by collapsing the two must not do so without re-deriving why they were separate in the first place. ruff format/ruff check --fixmay reformat files you just wrote, including reordering imports — always re-run the full test suite after auto-fixes, don't assume formatting-only changes are risk-free to skip re-verifying.
Change log for this document¶
- 2026-08-11 — Initial version, written at the conclusion of Slice E2
(
HANDOFF_SHA=48a93862f95981c7c97b47ae94cc8467196b92c5), per an explicit owner instruction to prepare a complete, lossless takeover package for a fresh implementation agent ahead of a usage-limit boundary.