Tier 1 — Reconciliation authority¶
Status: implemented as an inert Tier 1 primitive; production construction and
WRITE activation are not authorized.
Activation gate: Milestone 6; requires
ADR-013.
Related: confirmation_authority.md (this spec
reuses its signature mechanism), state_machine.py's
RECONCILIATION -> {VERIFIED, FAILED, ROLLING_BACK, ROLLED_BACK,
ROLLBACK_FAILED} manual-only edges. store.resolve_reconciliation() is the
sole authenticated resolver; it is not constructed by production.
The implemented signed payload is ed25519-reconciliation-v2. Version 2 adds
the applied-state lifecycle-locator observation required by ADR-026. Version 1
signatures are not reinterpreted or upgraded and fail closed.
Purpose¶
Define the authenticated service that resolves a contract sitting in
RECONCILIATION — the state a contract enters whenever the true
real-world outcome of a mutation or rollback attempt cannot be proven
automatically (timeout after send, lost response, ambiguous read-back).
Today, store.py has no code path that ever leaves RECONCILIATION; this
spec defines the one path that will exist, and it must be at least as
strong an authority check as confirmation, because a wrong reconciliation
decision is exactly as dangerous as a wrong initial mutation — it directly
sets the contract's final, trusted, audited state.
Security goals¶
- G1: Only a human operator who has independently observed pfSense's
actual state can resolve a
RECONCILIATION— never an automatic inference from HTTP status, response presence, or elapsed time. - G2: A reconciliation decision cannot be reused for a different contract or a different resolution than the one specifically signed.
- G3: A reconciliation decision cannot be forged by anything with only MCP-caller-level access (no prompt, no tool argument, no LLM output can satisfy this).
- G4: The recorded reconciliation outcome is exactly one of a small, closed set of typed conclusions — never free text that a downstream reader would have to interpret.
Invariants¶
- I1: Reconciliation evidence reuses
ConfirmationEvidence's shape conceptually but is a distinct type (ReconciliationEvidence) bound via a distinctDigestPurpose(new:RECONCILIATION), so a confirmation signature can never be presented as a reconciliation signature or vice versa (domain separation, same principlecanonical.pyalready applies to target-identity vs. fingerprint vs. intent digests). - I2:
ReconciliationEvidencebindscontract_id,operation_id, the contract's state at the time of observation (state_version), and the declared outcome (one of the closed enum values below) into what is signed — so the signature covers not just "I looked" but "I looked and concluded X." - I3: The declared outcome enum is exactly:
CONFIRMED_APPLIED(mutation took effect; store should move towardVERIFIED),CONFIRMED_NOT_APPLIED(mutation did not take effect; store should move towardFAILED),CONFIRMED_ROLLBACK_APPLIED(rollback took effect;ROLLED_BACK),CONFIRMED_ROLLBACK_NOT_APPLIED(ROLLBACK_FAILED). There is noUNKNOWN/RETRYvalue — an operator who cannot determine the outcome does not resolve the contract; it remains inRECONCILIATIONuntil they can. - I4: Resolution requires the contract's
state_versionat resolution time to match what the evidence was signed against (the same compare-and-set disciplinestore.pyalready applies everywhere else) — a reconciliation decision signed against a stale view of the contract is refused, not silently applied to whatever the current state happens to be. - I5: The resolver, like the confirmation verifier, never persists raw proof bytes and never leaks which specific check failed.
- I6: Every applied outcome signs an exact observed lifecycle locator that
must equal the contract's integrity-protected locator guard. A
CONFIRMED_APPLIEDoutcome additionally signs the exact authoritative post-forward fingerprint. Reconciliation cannot be used to infer continuity across a locator change or to create rollback eligibility without B.
Trust boundaries¶
| Boundary | Trusted side | Untrusted side | Enforcement point |
|---|---|---|---|
| Operator observation vs. automated inference | Signed ReconciliationEvidence |
Any HTTP status/timing/heuristic the executor observed | RECONCILIATION is manual-only in state_machine.py (existing); no automated path in or out |
| Reconciliation signature vs. confirmation signature | Distinct DigestPurpose.RECONCILIATION |
A confirmation signature for the same contract | Domain-separated digest (I1) — cannot be cross-presented |
| Declared outcome vs. actual pfSense state | Operator's independent read of pfSense (outside this system) | The contract's own stored (possibly stale/ambiguous) view | This module does not verify the outcome is true — that is the operator's responsibility outside the system; the module verifies only that an authorized operator declared it (see Non-goals) |
State ownership¶
src/pfsense_mcp/tier1/reconciliation.py(new module) ownsReconciliationEvidence,ReconciliationVerifier(Protocol, mirroringConfirmationVerifier's shape exactly), and the resolution function that performs the store transition.store.pygains one new method,resolve_reconciliation()(see Interfaces) — this is new store surface, not a repurposing oftransition(), because reconciliation resolution has a different authorization requirement (ReconciliationEvidence, notConfirmationEvidence) and a different set of legal target states.
Interfaces¶
# src/pfsense_mcp/tier1/reconciliation.py (new; not created yet)
class ReconciliationOutcome(str, Enum):
CONFIRMED_APPLIED = "confirmed_applied"
CONFIRMED_NOT_APPLIED = "confirmed_not_applied"
CONFIRMED_ROLLBACK_APPLIED = "confirmed_rollback_applied"
CONFIRMED_ROLLBACK_NOT_APPLIED = "confirmed_rollback_not_applied"
_OUTCOME_TARGET_STATE = {
ReconciliationOutcome.CONFIRMED_APPLIED: RecoveryState.VERIFIED,
ReconciliationOutcome.CONFIRMED_NOT_APPLIED: RecoveryState.FAILED,
ReconciliationOutcome.CONFIRMED_ROLLBACK_APPLIED: RecoveryState.ROLLED_BACK,
ReconciliationOutcome.CONFIRMED_ROLLBACK_NOT_APPLIED: RecoveryState.ROLLBACK_FAILED,
}
@dataclass(frozen=True)
class ReconciliationEvidence:
authority_id: str
algorithm: str
contract_id: str
operation_id: str
observed_state_version: int
outcome: ReconciliationOutcome
issued_at: datetime
proof: bytes
verified_target_fingerprint: str | None = None
verified_lifecycle_locator: int | None = None
# __post_init__ validation mirrors ConfirmationEvidence exactly
# (safe-token checks, UTC checks, bounded proof size).
@property
def evidence_digest(self) -> str:
"""digest_value(DigestPurpose.RECONCILIATION, {...}) — same
construction discipline as ConfirmationEvidence.evidence_digest."""
class ReconciliationVerifier(Protocol):
def verify(self, evidence: ReconciliationEvidence) -> bool: ...
# store.py addition:
def resolve_reconciliation(
self,
contract_id: str,
*,
evidence: ReconciliationEvidence,
) -> RecoveryContract:
"""Loads contract, requires state == RECONCILIATION and
state_version == evidence.observed_state_version, requires a
configured ReconciliationVerifier (fail closed if None, identical
discipline to confirm()), verifies evidence, transitions to
_OUTCOME_TARGET_STATE[evidence.outcome] with manual=True (the one
legitimate caller of require_transition(..., manual=True) in the
entire codebase), records the resolution as an audit event distinct
from ordinary state_transition (event_type="reconciliation_resolved")."""
Failure modes¶
| Failure | Detection | Resulting state | Automatic retry |
|---|---|---|---|
| No verifier configured | Same fail-closed check as confirm() |
ConfirmationError-family exception; contract remains in RECONCILIATION |
No |
| Forged/invalid signature | ReconciliationVerifier.verify() returns False |
Refused; contract remains in RECONCILIATION |
No |
Stale observed_state_version |
Compare-and-set mismatch | ContractConflictError; operator must re-observe current state and re-sign |
No — this is intentional: a stale observation must not resolve a contract that has since changed |
| Evidence for wrong contract/operation | Binding check (mirrors ConfirmationEvidence.verify_bindings) |
Refused | No |
Contract not actually in RECONCILIATION |
state == RECONCILIATION precondition |
Refused (ContractConflictError) |
No |
Recovery behavior¶
- A contract can remain in
RECONCILIATIONindefinitely — this is by design (is_terminal()instate_machine.pyalready excludesRECONCILIATIONfrom terminal states, meaning it's expected to persist across restarts until resolved). No automatic timeout moves it anywhere else; adding one would reintroduce an automatic-inference path this spec specifically forbids (G1). - If the whole-store anti-rollback anchor (see
whole_store_anti_rollback.md) detects a rollback, contracts that were previously resolved out ofRECONCILIATIONand are reintroduced at an earlier state must be forced back intoRECONCILIATION— the resolution history for that specificstate_versionsequence is no longer trustworthy relative to the current real world.
Non-goals¶
- This module does not verify that the operator's declared outcome is
actually true — it verifies only that a specific authorized operator,
identified by a valid signature, declared a specific outcome for a
specific contract at a specific observed state. Ensuring the operator
actually checked pfSense correctly is an operational/runbook
responsibility (see
disposable_lab_execution_model.mdand the eventual production runbook), not something software can verify. - This module does not implement multi-party approval (e.g., two
operators must agree) for v1.
ADR-013records this as a considered, rejected-for-now option and the condition under which it should be revisited (e.g., if reconciliation events become frequent enough to justify the added friction). - This module does not attempt to automatically retry the original mutation under any circumstance — reconciliation only records what happened; it never causes a second send.
Required tests¶
- Valid evidence with each of the four
ReconciliationOutcomevalues → contract transitions to the correct target state. - Stale
observed_state_version→ refused, contract state unchanged. - Evidence bound to a different
contract_id/operation_id→ refused. - No verifier configured → refused (fail-closed parity with
confirm()). - Contract not in
RECONCILIATION→ refused. - Cross-domain replay: a valid
ConfirmationEvidencesignature bytes cannot be reinterpreted as validReconciliationEvidence(different digest purpose makes the signed bytes different) — explicit test, not just an assumption. - Audit event for
reconciliation_resolvedis present, correctly chained, and HMAC-verified alongside ordinarystate_transitionevents in_verified_audit_rows.
Activation requirements¶
- [x]
ADR-013accepted. - [x]
reconciliation.pyimplemented and tested (tests/tier1/test_reconciliation.py, 8 tests). - [x]
store.resolve_reconciliation()implemented and tested (tests/tier1/test_store_reconciliation.py, 11 tests). - [ ] Operator runbook exists describing exactly how to independently
observe pfSense's actual state for each capability before signing
a reconciliation decision — capability-specific, written alongside
the first adapter (see
capability_adapter_contract.md), not generic. Not written in this pass — no capability/adapter exists yet to write it against. - [x] Reuses the same signing mechanism built for
confirmation_authority.md— landed as a shareded25519_authority.pymodule (extracted from the two nearly- identical implementations, not designed speculatively in advance) rather than literally extending the confirmation module, with a distinct accepted-algorithm string and signing-payload shape so a confirmation signature can never verify as a reconciliation signature. Proven bytest_confirmation_signature_cannot_be_replayed_as_reconciliation. The separate signing-side CLI tool itself remains unbuilt, same status asconfirmation_authority.md's equivalent item.
Implementation checklist¶
- [x] Create
src/pfsense_mcp/tier1/reconciliation.py. - [x] Add
DigestPurpose.RECONCILIATIONtocanonical.py. - [x] Add
store.resolve_reconciliation(). - [x] Add
reconciliation_resolvedas a recognizedevent_typealongsidecontract_created/contract_confirmed/state_transition(extend, don't replace, the existing audit event-type vocabulary).
Review checklist¶
- [ ] Confirm
resolve_reconciliation()is the only caller ofrequire_transition(..., manual=True)in the codebase — grep to verify no other code path can exitRECONCILIATION. - [ ] Confirm the four-outcome enum has no gap that would let an operator express "I don't know" as anything other than not resolving the contract at all.
- [ ] Confirm evidence digest construction is genuinely domain-separated
from
ConfirmationEvidence's (write the cross-domain-replay test before declaring this done, not after).
Security checklist¶
- [ ] No raw proof bytes persisted (parity check with
confirm()'s existing behavior). - [ ] Failure messages generic, no distinguishing detail (I5).
- [ ] Confirm
resolve_reconciliation()cannot be reached with aConfirmationEvidenceobject by type-checking alone (mypy strict mode should already catch this given distinct types, but add an explicit runtimeisinstanceguard as defense in depth, consistent with how the rest oftier1favors explicit checks over implicit trust in type annotations).
Test checklist¶
- [ ] All four outcome-transition tests.
- [ ] Stale-version refusal test.
- [ ] Cross-contract binding refusal test.
- [ ] Cross-domain (confirmation vs. reconciliation) signature refusal test.
- [ ] Fail-closed-with-no-verifier test.
- [ ] Audit event presence/chaining test.
- [ ] Negative test: module has zero forbidden imports (AST isolation).