ADR-025: Authorization-to-RecoveryContract binding¶
- Status: Accepted (2026-08-12, owner)
- Date: 2026-08-11
- Scope: Architecture only. This ADR authorizes no schema, code, test, production-construction, MCP, capability, or WRITE change.
Owner convergence decision¶
The owner accepted Alternative F and the first-WRITE-specific decisions in this ADR on 2026-08-12. Acceptance fixes the authorization-to-contract architecture; it does not authorize W1 implementation, production construction, endpoint/capability activation, MCP WRITE registration, or live execution.
The first production path uses PlanAuthorizationV2 exclusively:
authoritative preparation
→ PreparedExecutionIntentV1 (or its explicit successor version)
→ exact execution-intent digest beside the exact plan step
→ pinned signature/currentness/plan-freshness verification
→ authoritative intent reconstruction and exact digest comparison
→ durable one-time authorization consumption
→ one provenance-bound authenticated RecoveryContract
→ exact-contract confirmation
→ one MutationExecutor handoff
PlanAuthorization v1 and legacy contracts are never execution-eligible by
inference. MutationExecutor remains authorization-unaware and the sole owner
of reads at send boundaries, locator/fingerprint validation, sends, post-write
verification, retry suppression, rollback, and recovery transitions.
Authorization must still be current when the executable contract is created and confirmed. Contract expiry is bounded by authorization expiry; the authenticated provenance records the authorization expiry/currentness facts needed to reject a stale conversion. Once a legitimate send has occurred, authenticated RecoveryContract/state-machine recovery, reconciliation, and rollback remain available according to their existing safety rules: expiry cannot strand an already-mutated target or demand a new authorization merely to restore safety.
One-time consumption precedes creation. All non-mutating feasibility checks must complete first; after consumption the coordinator may create exactly one matching contract and may hand exactly that contract to the executor once. A crash or create failure burns the authorization and grants no retry. W1 must make the consume→create ordering deterministic and fail closed using the existing stores; it must stop for owner review if that narrow race cannot be closed without a new authority or a generic distributed transaction mechanism.
Appliance target binding¶
First-WRITE authorization is appliance-specific. The strongest existing repository-native material is composed rather than replaced:
- normalized configured
PFSENSE_API_URLand its enforced TLS configuration; - one fresh persistent installation identifier already modeled by the READ
layer:
SystemStatus.netgate_id, withSystemHaSync.pfhostidas the repository-modeled alternative; - a domain-separated appliance-target binding included in the prepared execution authorization/provenance chain and checked again by fixed production construction.
These identifiers are ordinary TLS-authenticated appliance data, not hardware attestation, and remain privacy-sensitive. The production WRITE path may read them only for this protected binding and must not log or expose them. If neither identifier is available, or if the configured target/TLS binding or observed identifier changes, WRITE preparation/execution fails closed. No base-URL-only, hostname-only, nullable “unknown appliance,” or caller-supplied identity fallback is accepted. The exact additive field/schema placement is a narrow W1 implementation detail; it must be covered by the same canonical intent/provenance integrity and must not alter PlanDigest v1 retrospectively.
Pinned Ed25519 authorities verify authorization, confirmation, and reconciliation in their existing separate domains. Production contains no signing private key, accepts no authority material from MCP input, and cannot self-authorize, self-confirm, or fabricate reconciliation evidence.
Implementation status¶
Slice B1 — prepared execution-intent model and canonical digest — implemented
(2026-08-11), under a fixed owner scope. New inert module
src/pfsense_mcp/tier1/prepared_execution_intent.py defines the frozen,
explicitly versioned PreparedExecutionIntentV1, its sole canonical payload
function, and compute_execution_intent_digest(). The existing canonical
owner gained only DigestPurpose.EXECUTION_INTENT; no second JSON/hash path was
introduced.
The B1 semantic fields are schema version, WRITE capability, endpoint symbol, mutating HTTP method, adapter-semantics version, resource-level natural target, exact target precondition, full normalized mutation intent, exact rollback snapshot, and rollback-plan version. The four canonical values are deeply frozen after validation and exposed only as defensive copies. Adapter version plus normalized intent owns request/post-condition interpretation, so a second expected-post-condition description would be duplicative. Generated IDs, lifecycle state, expiry, authorization/plan/step provenance, digests of fields, confirmation, and appliance identity are deliberately absent.
The digest domain is the existing framed Tier 1 v1 prefix plus the new
execution-intent purpose and fixed PreparedExecutionIntentV1 context. The
schema version also participates in the payload. Unsupported versions fail
closed; there is no V0/legacy inference.
B1 remains synthetic and production-unreachable. It does not implement B2's PlanAuthorization v2 binding, B3's preparer, B4's RecoveryContract provenance, B5's freshness composition, or B6/E3. ADR-025 is Accepted but unimplemented beyond B1/B2; public MCP remains 42 READ / 0 WRITE and WRITE remains 0/3 active.
Slice B2 — PlanAuthorization v2 signed per-step binding — implemented
(2026-08-11), under a fixed owner scope. PlanAuthorizationV2 is a separate
concrete artifact with schema version 2. It replaces v1's independent
authorized_step_ids with one authoritative, non-empty tuple of typed
PlanAuthorizationStepBinding(step_id, execution_intent_digest) values.
Duplicate step IDs, malformed safe-token step IDs, and non-64-lowercase-hex
digest values fail construction. Binding order has no semantic meaning; the
signed payload sorts exact (step_id, digest) pairs deterministically.
V2 signs the existing plan digest, the exact binding set, authority/algorithm,
timestamps, risk class, and evidence fingerprint under the distinct
plan-authorization-v2 purpose plus signed schema version 2. V1 remains its
unchanged concrete type/domain/schema and cannot verify as v2 (or vice versa).
Expiry remains the same exclusive now < expires_at check and the same pinned
Ed25519 authority mechanism is reused.
B2 validates and signs only the B1 digest's stable 64-hex output shape. It does not import a prepared intent, prepare or recompute an execution-intent digest, claim freshness/consumption, or create a RecoveryContract. A same-shaped digest from another domain cannot be distinguished structurally at B2; B3/B5 must authoritatively recompute the B1 domain and compare it. B3–B6/E3 remain unimplemented and unauthorized. ADR-025 is Accepted; public MCP remains 42 READ / 0 WRITE and WRITE remains 0/3 active.
Context¶
ADR-022 defines Plan → Authorize → Execute → Verify. ADR-024 Slice E1
implemented regenerated-plan freshness, and Slice E2 implemented ordered
authorization verification through durable one-time consumption. Slice E3 was
stopped before implementation because the authorization domain and Tier 1
execution domain have no proof-carrying common binding.
PlanAuthorization signs an exact plan digest and a set of step IDs. The v1
plan digest describes security-posture policy and ordering. A
RecoveryContract instead binds the concrete Tier 1 operation that the sealed
executor may send. Neither an ID copied between the objects nor a caller's
assertion proves that these meanings correspond.
This ADR designs the smallest safe bridge, including the accepted additive appliance-target binding defined above.
Problem statement¶
The current objects prove two disconnected statements:
- an authority approved a current plan and named step; and
- confirmation approved the exact target and intent in a RecoveryContract.
They do not prove that the contract represents the approved step. A caller could present authorization for plan A/step A and select the capability, endpoint, method, target, or mutation body for contract B. Consuming the authorization first limits replay but does not remove this substitution.
The exact missing relation is:
signed (plan digest, step ID)
?
concrete (capability, endpoint, method, target, precondition,
normalized intent, recovery inputs)
Existing domains and missing binding¶
PlanAuthorization¶
The signed v1 payload contains schema_version, authorization_id,
plan_digest, ordered authorized_step_ids, authority_id, algorithm,
issued_at, expires_at, risk_class, and evidence_fingerprint. proof
authenticates that payload but is not itself inside it. Signature, currentness,
scope, freshness, and one-time consumption are already separate fail-closed
gates.
SecurityPosturePlan and PlanStep¶
The plan describes desired capability posture, anchor assurance, validity, axis transitions, findings, and ordered provisioning/activation steps. Its steps are not resource-mutation requests. The v1 digest includes each step's ID, order, axis, mutation class, and authorization level. It intentionally does not include the Tier 1 capability, endpoint symbol, method, target, precondition, normalized mutation intent, snapshot, or rollback policy.
RecoveryContract¶
The contract binds capability, endpoint_symbol, http_method, target
identity digest, target fingerprint, intent digest, snapshot digest, rollback
plan version, protected target/intent/snapshot values, lifecycle data, and
derived idempotency identity. It contains no source plan digest, step ID,
authorization ID, or signed execution-intent digest.
ConfirmationEvidence and MutationExecutor¶
Confirmation evidence binds the exact contract ID, operation ID, target
identity digest, target fingerprint, intent digest, and expiry. It does not
bind plan or authorization provenance. MutationExecutor.execute() accepts a
contract ID, capability adapter, and intent, loads the authoritative contract,
rechecks its bindings and state, performs one mutation, verifies the
post-condition, and records sealed transitions/audit. It must remain
authorization-unaware.
Security invariant¶
For a coordinator-created RecoveryContract to be accepted for execution, a pinned authority must have signed one unique association between an exact fresh plan digest, one exact authorized step ID, and one domain-separated digest of the complete execution intent. The coordinator must independently recompute that digest from one trusted, typed prepared intent, consume the authorization, construct exactly one contract from the same prepared intent, and prove the persisted contract carries the same execution tuple and provenance. No caller may supply or replace a preconstructed contract or any parallel execution-critical value after that check.
Confirmation then binds the exact contract, and the existing executor consumes only that contract's sealed execution facts. This is a transitive binding. The accepted appliance-target binding above also makes the prepared authorization and contract provenance specific to the configured appliance.
Field-by-field binding matrix¶
Legend: S signed, P v1 plan-digest-bound, R RecoveryContract-bound, C confirmation-bound, X executor-consumed, D derived, V persisted. An omitted marker means “no.” “Caller” means selectable at the current API boundary. Security-critical fields are marked SC; a row that is not marked SC is descriptive/policy metadata only. Each row's final column identifies whether an unclosed substitution surface exists.
PlanAuthorization¶
| Field | Existing binding/use | Caller / derived / persisted | Security and substitution assessment |
|---|---|---|---|
schema_version |
S, V | signer-selected under verifier policy | SC; must fail closed by version |
authorization_id |
S, V; consumption key | signer-generated | SC replay identity; copying it into a contract would prove only provenance |
plan_digest |
S, V | computed from plan, then signed | SC; authenticates v1 plan semantics, not execution tuple |
authorized_step_ids |
S, V | signer-selected, canonical ordering/uniqueness enforced | SC; names policy steps only |
authority_id |
S, V | pinned authority | SC authentication/routing, not execution intent |
algorithm |
S, V | verifier-supported | SC algorithm selection |
issued_at, expires_at |
S, V | signer-selected within policy | SC currentness window; neither binds contract expiry |
risk_class |
S, V | signer-selected | SC authorization policy, not a mutation description |
evidence_fingerprint |
S, V; also P through plan | derived from posture evidence | SC freshness context, not Tier 1 target or intent |
proof |
authenticates signed payload | authority-generated, V | SC; excluded from its own payload by design |
| proposed per-step execution binding | absent in v1 | signer approves a preparer-derived digest | SC; required v2 association |
SecurityPosturePlan¶
| Field/class | Existing binding/use | Caller / derived / persisted | Security and substitution assessment |
|---|---|---|---|
| plan schema/version | P | planner-owned | SC for canonical meaning; v1 cannot imply future execution semantics |
| current discovery/evidence | summarized by P fingerprint | live discovery, planner-derived | SC freshness input; not a Tier 1 target snapshot |
| target capability posture | P | caller requests; planner validates | SC policy goal, insufficient to select mutation tuple |
| target anchor assurance | P | caller requests; planner validates | SC policy goal, insufficient to select mutation tuple |
| target validity/axis transitions | P | planner-derived | policy-level; descriptive of posture transition |
overall status / safe_to_proceed |
P | planner-derived | SC gate; not execution mapping |
| findings/notes | not all P | planner-derived | descriptive/audit; must not become hidden execution authority |
| ordered steps | selected fields P | planner-derived | SC scope/order, but current steps are provisioning policy actions |
| appliance target identity | absent from current v1 plan | configured target/TLS plus protected netgate_id or pfhostid |
additive first-WRITE intent/provenance binding required by W1; absence fails closed |
PlanStep¶
| Field | In v1 plan digest? | Can derive Tier 1 execution? | Meaning and substitution assessment |
|---|---|---|---|
step_id |
yes | no | stable policy-step name; same name does not define a resource mutation |
order |
yes | no | sequencing only |
axis |
yes | no | posture axis only |
action |
no | no | policy action label, not canonical Tier 1 input |
description |
no | no | human-readable and never authoritative |
mutation_class |
yes | no | broad class; many capabilities/endpoints/intents share it |
authorization_required |
yes | no | authorization level, not operation data |
implementation_available |
no | no | rollout status only |
reversible |
no | no | policy characterization; does not define rollback artifact |
security_impact |
no | no | descriptive risk text |
prerequisite_satisfied |
no | no | planner status |
blocked, blocked_reason |
no | no | planner status/explanation |
evidence |
no | no | descriptive supporting evidence, not a target precondition |
No current PlanStep field is sufficient to derive a capability, endpoint, method, resource target, canonical body, snapshot, or rollback plan.
RecoveryContract¶
| Field | Existing binding/use | Caller / derived / persisted | Security and substitution assessment |
|---|---|---|---|
contract_id |
R, C, X, V | construction input today; proposed coordinator-generated | SC object identity; must not be caller-substitutable |
operation_id |
R, C, V | construction input today; proposed coordinator-generated | SC operation/audit identity |
idempotency_key |
R, X, D, V | derived by existing contract machinery | SC replay guard, not authorization proof |
capability |
R, X, V | currently construction input | SC; unbound to plan authorization |
endpoint_symbol |
R, X, V | currently construction input | SC; unbound to plan authorization |
http_method |
R, X, V | currently construction input | SC; unbound to plan authorization |
target_identity_digest |
R, C, X, D, V | derived from resource identity | SC resource binding; not appliance authorization identity |
target_fingerprint |
R, C, X, D, V | derived from precondition | SC stale-target check |
intent_digest |
R, C, X, D, V | derived from normalized intent | SC mutation-body binding |
snapshot_digest |
R, X, D, V | derived from rollback snapshot | SC recovery binding; not confirmation-bound today |
rollback_plan_version |
R, X, V | construction input | SC recovery policy/version |
| protected target/intent/snapshot | R, X, V | encryption of exact prepared values | SC; randomized ciphertext is not suitable signed canonical input |
created_at, expires_at |
R, X, V | coordinator/policy-derived | SC lifecycle bounds; not operator mutation choice |
state, state_version |
R, X, V | state machine/store-owned | SC; never caller-mutated directly |
| confirmation digest/time | R, X, D, V | store confirmation transition | SC evidence/audit state |
| source plan/step/auth/digest | absent | proposed coordinator-derived | SC durable provenance; alone would not prove tuple correspondence |
ConfirmationEvidence¶
| Field | Bound today | Not bound today |
|---|---|---|
| authority/algorithm/nonce/proof | authenticates one confirmation payload | plan/step/authorization provenance |
| contract and operation IDs | exact RecoveryContract identity | why that contract was authorized |
| target identity/fingerprint | exact contract target/precondition | appliance-level authorization target |
| intent digest | exact contract mutation intent | plan semantics independently |
| issued/expiry times | evidence/currentness window | authorization lifetime |
MutationExecutor invocation¶
| Input/value | Origin and validation | Substitution surface |
|---|---|---|
contract_id |
caller today; authoritative contract loaded from store | coordinator must pass only ID returned by its own successful create |
| adapter | caller today; executor verifies adapter capability/endpoint/method against contract | future coordinator must select from a sealed registry using the bound tuple, not accept an arbitrary parallel choice |
intent |
caller today; normalized and compared with contract digest | future coordinator must reuse the exact prepared intent, not accept a post-consumption replacement |
| target and precondition | adapter reads; executor compares digests | protected by existing contract/executor checks |
| request and result | adapter/executor-owned | sealed executor/state machine own send, verification, recovery, and audit |
Deterministic derivation today¶
It is not possible to derive an exact RecoveryContract execution tuple from the current authorized plan and step without caller-supplied security-critical choices.
Missing semantic data includes capability, endpoint symbol, method, resource target selector/identity, expected target precondition, normalized mutation parameters/body, rollback snapshot input, and rollback-plan version. Missing cross-reference data includes source plan digest, step ID, authorization ID, and an execution-intent digest. Adding only the cross-references cannot supply or authenticate the missing semantics.
Trust boundaries and source-of-truth ownership¶
The proposed design gives each fact one semantic owner:
| Fact | Authoritative owner |
|---|---|
| posture plan, policy step, ordering | security-posture planner |
| plan digest | plan-digest module |
| approval, scope, lifetime, risk | pinned signing authority / PlanAuthorization |
| capability, endpoint, method, typed mutation parameters | capability-specific execution-intent preparer selected from a sealed registry |
| target selector/identity and precondition | preparer using authoritative adapter read |
| normalized intent | existing capability adapter normalization |
| rollback snapshot and rollback-plan version | preparer plus capability recovery policy |
| execution-intent digest | canonical module's dedicated domain over prepared intent |
| source plan/step/authorization association | PlanAuthorization v2 signed per-step binding |
| IDs, timestamps, encryption, contract construction | coordinator plus existing RecoveryContract machinery |
| contract persistence and lifecycle | RecoveryContract store/state machine |
| confirmation validity | existing confirmation verifier/store |
| send, post-condition, recovery, audit | existing MutationExecutor/state machine/store |
The coordinator composes these authorities. It does not become a second planner, canonicalizer, adapter, confirmation verifier, or state machine.
Alternatives considered¶
Alternative A — extend PlanStep with the entire execution intent¶
This could make the plan digest directly cover all operation facts, but it would turn a general security-posture plan into a Tier 1 resource-mutation schema. The current planner does not possess authoritative target snapshots or typed mutation parameters, and some plan steps concern configuration files, physical TPM work, or capability activation outside RecoveryContract's resource model. Mirroring adapter and recovery semantics in PlanStep would create two sources of truth and force broad plan/digest version churn.
Rejected as the primary design. A plan may display an opaque execution binding for review, but must not duplicate the execution schema.
Alternative B — add only an execution-intent digest to PlanStep¶
A digest is strong only if an authoritative component can independently construct the preimage. Putting an unexplained digest in PlanStep would let a caller choose both the digest and the later contract. If the preparer and signer workflow are specified, this can work, but the digest belongs to a separate execution-intent domain rather than the PlanStep model itself.
Retained as part of the recommended design, but not as a bare field. The signed authorization associates the digest with the exact plan and step.
Alternative C — add plan/step provenance fields to RecoveryContract¶
source_plan_digest, source_step_id, and source_authorization_id improve
auditability. They do not prove that capability, endpoint, method, target, or
intent correspond to that source. A malicious constructor can copy valid IDs
onto an unrelated contract.
Rejected alone; retained only as durable provenance plus the verified execution-intent digest.
Alternative D — sign per-step execution-intent digests directly¶
PlanAuthorization v2 can sign an ordered, duplicate-free association of step ID to execution-intent digest. The plan digest remains the identity of the fresh policy plan; the new digest identifies concrete execution. The signer approves both without making the verifier or executor understand adapter internals.
This creates a clear version boundary and requires the operator/signing workflow to receive a reviewable prepared intent. It couples authorization to the existence of an execution-intent digest, but not to RecoveryContract's state or executor implementation.
Accepted as the cryptographic association in the recommended design.
Alternative E — fixed mapping registry from current step metadata¶
Current axis and mutation_class values are not one-to-one with Tier 1
operations. A registry would hide unsigned policy outside the plan digest,
become vulnerable to code-version skew, and still could not derive target,
parameters, snapshot, or recovery policy.
Rejected. A sealed registry may select a capability-specific preparer only after the signed execution binding exists; it may not invent the binding from current PlanStep metadata.
Alternative F — signed prepared-execution binding with contract provenance¶
Create a frozen, typed PreparedExecutionIntentV1 before authorization. A
capability-specific preparer derives all execution-critical plaintext facts
from typed request data, authoritative target read, existing adapter
normalization, and recovery policy. The sole canonical module computes a new
domain-separated digest. PlanAuthorization v2 signs the exact
(step_id, execution_intent_digest) association alongside plan_digest.
After verifying and recomputing everything and before creating a contract, the coordinator consumes the authorization. It constructs the contract itself from that same immutable prepared value. The contract persists source plan, step, authorization, and execution-intent digest as authenticated provenance. The coordinator verifies the created contract's existing digests and tuple against the prepared value before persistence/transition. It never accepts a preconstructed contract.
Recommended. This combines D's signed association, B's compact binding, and C's durable provenance while assigning semantic derivation to one capability-specific preparer.
Alternative ranking¶
Scores are relative: 5 is strongest/best. “F” is the recommended composite.
| Criterion | A | B alone | C alone | D alone | E | F |
|---|---|---|---|---|---|---|
| substitution resistance | 4 | 2 | 1 | 4 | 2 | 5 |
| cryptographic/structural completeness | 4 | 2 | 1 | 4 | 1 | 5 |
| single source of truth | 2 | 2 | 3 | 4 | 2 | 5 |
| deterministic reproducibility | 2 | 2 | 1 | 4 | 2 | 5 |
| minimal schema churn | 1 | 4 | 3 | 3 | 5 | 2 |
| minimal coupling | 1 | 4 | 4 | 3 | 2 | 4 |
| auditability | 4 | 2 | 4 | 3 | 2 | 5 |
| fail-closed behavior | 3 | 2 | 1 | 5 | 2 | 5 |
| migration clarity | 2 | 3 | 2 | 5 | 2 | 5 |
| testability | 3 | 2 | 2 | 4 | 2 | 5 |
| E1 freshness compatibility | 2 | 3 | 5 | 5 | 2 | 5 |
| E2 consumption compatibility | 4 | 3 | 3 | 5 | 3 | 5 |
| RecoveryContract/state-machine compatibility | 2 | 3 | 2 | 4 | 3 | 4 |
| executor remains authorization-unaware | 5 | 5 | 5 | 5 | 5 | 5 |
Recommended design¶
1. PreparedExecutionIntentV1¶
Define one frozen internal model whose semantic fields are:
- Tier 1 capability;
- endpoint symbol;
- mutating HTTP method;
- canonical resource target identity;
- canonical expected target precondition;
- normalized mutation intent;
- canonical rollback snapshot;
- rollback-plan version.
The exact types must reuse existing canonical-value, capability, endpoint, method, and adapter types. Randomized ciphertext, generated contract and operation IDs, timestamps, lifecycle state, state version, confirmation, and idempotency key are excluded: they are contract mechanics rather than choices about what operation is authorized.
The snapshot and rollback-plan version are included because recovery behavior is security critical. The preparer must obtain target identity, precondition, and snapshot before signing; an implementation that cannot do this deterministically must stop rather than ask the coordinator caller to fill the gap.
2. Execution-intent digest¶
Add exactly one DigestPurpose.EXECUTION_INTENT to the existing canonical
module. Its owner is the prepared-execution binding subsystem. It hashes the
canonical plaintext model above, not encrypted artifacts and not an existing
RecoveryContract serialization.
The digest intentionally excludes plan digest and step ID. PlanAuthorization v2 is the outer signed object that associates all three domains. This keeps the execution digest reusable for independent recomputation and prevents a recursive or duplicated source of truth.
3. PlanAuthorization v2¶
Add an ordered, duplicate-free authorized_executions collection. Each entry
contains exactly step_id and execution_intent_digest. Every executable
authorized step must have exactly one entry; no extra entry may exist. The
collection is signed with the existing plan digest and other authorization
fields.
PlanAuthorization v1 remains valid for existing non-execution verification and historical audit, but is categorically ineligible to create a RecoveryContract. No verifier may synthesize a digest for v1.
4. Durable RecoveryContract provenance¶
A new contract schema version persists:
source_plan_digest;source_step_id;source_authorization_id;source_execution_intent_digest.
These fields must be covered by the contract's authenticated persistence/HMAC payload and immutable after creation. They are not sufficient by themselves; creation must also recompute the execution digest from the prepared plaintext and verify that the contract's capability, endpoint, method, target identity digest, target fingerprint, intent digest, snapshot digest, and rollback-plan version all represent that same value.
Legacy RecoveryContracts may retain their existing recovery lifecycle, but they are ineligible for coordinator-originated execution. No legacy row is upgraded by filling provenance from an assertion.
5. Coordinator API shape¶
The eventual coordinator caller may provide only:
- the signed PlanAuthorization v2;
- the exact plan digest and step ID being requested;
- posture targets and environment needed by existing freshness;
- one immutable prepared-execution intent or an authoritative persisted reference to it;
- real ConfirmationEvidence after the contract exists;
- time.
The caller must not provide a RecoveryContract, contract ID for execution, capability, endpoint, method, target, precondition, snapshot, rollback version, or intent as parallel replaceable arguments. The coordinator selects the adapter/preparer through a sealed registry keyed by the already-bound capability and passes only the ID of the contract it created to confirmation and execution. Any conflict is a uniform pre-consumption denial.
Generated IDs, timestamps, contract expiry, encryption, and idempotency are derived internally under existing policy. Contract creation and confirmation require current authorization, and contract expiry must not outlive that authorization. Post-send recovery remains governed by authenticated lifecycle state rather than being disabled by authorization expiry.
Canonicalization and digest design¶
The canonical representation is a versioned object with fixed field names and the existing canonical value rules: NFC UTF-8 strings, sorted object keys, order-preserving lists, explicit booleans/integers/null, no floats, bounded depth/size, and enum values encoded by their stable wire strings. Optional fields must be represented according to one schema rule—prefer explicit null when absence has semantics—and may not oscillate between omitted and null.
The domain must be distinct from PLAN, PLAN_AUTHORIZATION, INTENT, TARGET, SNAPSHOT, CONFIRMATION, IDEMPOTENCY, and RecoveryContract persistence domains. A suitable semantic label is:
pfSense-MCP/Tier1/execution-intent/v1
Implementation should express this through the existing canonical module's
purpose mechanism, not a new hash helper. The exact byte prefix must be fixed
by tests before use. execution_intent_digest is a digest of the whole
prepared operation; the existing intent_digest remains the narrower digest
of mutation parameters. Target identity, precondition, snapshot, and intent
retain their existing digest domains inside RecoveryContract. No digest is
substitutable for another.
Canonical tests must cover reordered object keys, list order, Unicode normalization, enum encoding, null/omission, malformed/oversized values, version mismatch, and domain-confusion attempts.
Freshness consequences¶
The recommended design does not change PlanDigest v1 or ask the posture planner to generate Tier 1 mutation details. E1 continues to prove that the authorized policy plan is fresh by deterministic rediscovery and replanning.
Execution freshness becomes an additional, separate pre-consumption check:
the capability-specific preparer must rediscover/re-read the target and
reproduce the exact prepared execution digest, or the attempt fails. The
existing plan_authorization_is_fresh() API remains correct for plan
freshness; it must not be renamed or weakened to imply execution-target
freshness.
If implementation instead chooses to embed execution facts in PlanStep, both PlanDigest and deterministic replanning would require a new version and the planner would need all authoritative Tier 1 inputs. That is not recommended.
Code-version skew is fail closed: a preparer/canonical schema version that cannot reproduce the signed v1 execution digest cannot execute. The signing workflow should record the preparer schema/version for operator review.
Consumption consequences¶
All of the following occur before consumption:
- existing E2 gates;
- PlanAuthorization v2 eligibility and one-to-one step binding;
- authoritative prepared-intent reconstruction;
- exact execution-intent digest verification;
- adapter/preparer selection and contract-construction feasibility checks that do not persist a contract.
Consumption remains one authorization permits one attempt to create a
RecoveryContract. After successful consumption, the coordinator derives IDs,
constructs and persists exactly one contract. A crash or store.create()
failure burns the authorization and creates no retry entitlement. Provenance
does not create a claim/commit state and does not close that accepted window.
Confirmation consequences¶
No ConfirmationEvidence schema change is recommended. Existing evidence binds the exact created contract's identity, operation, target, precondition, intent, and expiry. Once contract creation proves and persists the signed execution binding, confirmation transitively approves the authorized execution.
The coordinator must use real evidence and exact contract ID. Confirmation for contract A cannot confirm B under the existing verifier/store. Snapshot and rollback policy remain protected by the contract and executor/recovery machinery, not independently added to confirmation in this ADR.
Executor and state-machine consequences¶
MutationExecutor remains authorization-unaware. It receives the exact
coordinator-created contract ID, a registry-selected matching adapter, and the
same normalized intent. Its existing contract-binding, one-send,
post-condition, recovery, transition, and audit behavior remains authoritative.
The state machine remains unchanged. The coordinator must use the legal
PREPARING → PREPARED transition after creation and before confirmation; it
must not mutate state directly or duplicate transitions. Direct executor
invocation remains controlled by production construction/isolation review,
not by a language-level authorization check inside the executor.
Target-identity separation¶
Resource identity and appliance identity remain distinct fields with distinct purposes. Alias natural identity/fingerprint protects which resource may be changed; the accepted appliance-target binding protects which configured pfSense installation may execute the authorization. Neither substitutes for the other. W1 must bind both into the protected authorization/contract lineage without changing PlanDigest v1 or exposing identifying metadata.
Caller influence analysis¶
| Caller-provided value | Can alter execution? | Required treatment |
|---|---|---|
| PlanAuthorization v2 | yes | pinned signature, expiry, scope, freshness, one-time consumption |
| requested plan digest / step ID | selects approved scope | exact match to signed fields; never used to populate unrelated contract fields |
| posture targets/environment | affects plan freshness | authoritative rediscovery/replanning; caller cannot provide fresh plan/digest |
| typed mutation request used by preparer | yes | normalized and authoritatively prepared before signing; full digest recomputed |
| prepared-intent reference | yes | load immutable authoritative record; reject caller-mutated copy |
| ConfirmationEvidence | permits transition | existing exact-contract verification only |
| time | affects currentness | injected trusted clock in production; bounded deterministic clock in tests |
The API must not accept a preconstructed contract or replacement adapter, intent, target, method, endpoint, snapshot, or rollback policy. If a prepared value is transported by the caller, the signed digest and authoritative live reconstruction make it an untrusted carrier, not an authority.
Threat analysis¶
| Threat | Result under recommended design |
|---|---|
| plan A/step A → contract B | prevented: signed step-to-execution digest plus contract tuple verification |
| same step ID in another plan | prevented: outer authorization binds exact plan digest and mapping |
| same mutation class, different parameters | prevented: normalized parameters are in execution digest |
| same endpoint, different method | prevented: method is in execution digest and contract |
| same capability, different endpoint | prevented: both are in execution digest and contract |
| same endpoint/method, different body | prevented: normalized intent is in execution digest and existing intent digest |
| parameter reordering/Unicode ambiguity | detected by sole canonicalizer and adapter normalization |
| stale planner version | detected by existing plan freshness/version boundary |
| prepared under one code version, executed under another | fail closed unless same versioned digest is reproduced |
| contract mutation after creation | detected by immutable model, authenticated store, state version, and existing bindings |
| caller-supplied preconstructed contract | prohibited by coordinator API and isolation tests |
| replay with another contract | prevented by atomic consumption and exact signed binding |
| copied authorization/plan IDs on unrelated contract | detected: provenance alone is insufficient; execution digest/tuple must match |
| digest-domain confusion | detected by distinct purpose/domain and negative tests |
| semantically similar but byte-different plan | rejected by exact plan digest/freshness |
| concurrent coordinator calls | at most one reaches creation through existing atomic consumption |
| confirmation for A applied to B | prevented by existing evidence contract/operation bindings |
| response lost after execution | existing durable contract state is authoritative; no automatic replay |
| direct executor invocation bypass | not solved cryptographically; production isolation/construction remains the boundary |
| authorization reused on another appliance | prevented by the accepted configured-target/TLS plus stable installation-identifier binding; missing/changed identity refuses WRITE |
Versioning and migration¶
The recommended design requires explicit fail-closed versions:
PreparedExecutionIntentschema v1 and EXECUTION_INTENT digest domain v1;PlanAuthorizationschema v2 with signed per-step execution bindings;- no PlanStep or PlanDigest version change under the recommended design;
- a RecoveryContract schema/provenance version and store schema migration (expected next store version, determined at implementation pre-flight);
- no ConfirmationEvidence schema change;
- no MutationExecutor authorization input.
PlanAuthorization v1 may continue to verify for its historical/read-only purpose but must be rejected for RecoveryContract creation. Existing contracts may continue their existing sealed recovery lifecycle; they must not be retroactively marked coordinator-authorized. Migration may add nullable storage columns only for legacy reading, while new coordinator contracts require non-null v2 provenance. No transition period may infer bindings.
Rollback of partially deployed schema support must preserve fail-closed ineligibility: older code must not execute v2 authorizations or misread new contracts. Exact database forward/backward compatibility needs an owner-approved migration plan before implementation.
Rejected designs¶
- Trusting a human-readable PlanStep action or description.
- Inferring a contract from
axisplusmutation_class. - Copying plan/step/authorization IDs into a contract without tuple proof.
- Letting the coordinator caller supply a contract beside authorization.
- Reusing
intent_digest,plan_digest, target digest, or contract HMAC as a generic execution digest. - Signing randomized encrypted artifacts instead of canonical plaintext semantics.
- Making MutationExecutor verify PlanAuthorization.
- Treating appliance identity as equivalent to execution-intent binding.
- Allowing v1 authorization to execute by compatibility inference.
Deferred work¶
- DeprovisionAuthorization verification and destructive-target binding.
- Two-phase authorization consumption.
- Concrete capability adapter/preparer implementation. Accepted ADR-026 specifies the lab-gated firewall-alias description-only semantic unit; its first-WRITE mandatory evidence matrix remains the W1/W3 gate.
- Production construction, MCP WRITE registration, allow-list population, and all three WRITE activation milestones.
Public MCP remains 42 READ / 0 WRITE; WRITE remains 0/3 active.
Historical implementation slices¶
These slices describe the accepted binding components. B1/B2 are implemented; B3-B6 are consolidated into separately authorized W1 by the definitive roadmap. Acceptance starts no implementation.
B1 — execution-intent model and canonical digest¶
- Invariant: one frozen, versioned prepared intent has one independently reproducible, domain-separated digest.
- Expected files: new internal binding model/module, existing canonical purpose enum, focused canonical/adversarial tests, minimum spec docs.
- Forbidden: coordinator, executor, state machine, MCP, production factory.
- Versions: PreparedExecutionIntent v1; EXECUTION_INTENT domain v1.
- Tests: all fields load-bearing; normalization/domain/version confusion; malformed, reordered, null, list, Unicode, and size cases.
- Migration: none; inert model only.
- Rollback: remove inert model before any signed artifact exists.
- STOP: any field cannot be singularly owned or canonically represented.
B2 — PlanAuthorization v2 per-step binding¶
- Invariant: a signature approves one exact plan digest and a duplicate-free step-to-execution-digest association.
- Expected files: authorization model/payload/verifier and tests; ADR/spec status notes only.
- Forbidden: RecoveryContract, executor, state machine, MCP.
- Versions: PlanAuthorization v2; v1 explicitly execution-ineligible.
- Tests: missing/extra/duplicate mappings, reordered encodings, cross-plan same-step IDs, wrong digest/domain, downgrade attempts.
- Migration: no auto-upgrade or synthesized binding.
- Rollback: v2 remains unused/inert until later slices.
- STOP: v1 could reach execution or signer cannot review the prepared intent represented by each digest.
B3 — authoritative capability-specific preparer¶
- Invariant: the first candidate's exact tuple is derived without caller-selected parallel execution facts and reproduces B1's digest.
- Expected files: isolated capability-specific preparer/registry and tests.
- Forbidden: MCP registration, WriteEndpoints population, coordinator production construction, executor/state-machine changes.
- Versions: explicit preparer schema/version pinned to B1.
- Tests: target/precondition/snapshot reads, intent normalization, mapping ambiguity, version skew, mutation parameter substitution, fake adapters.
- Migration: none; synthetic/test-only.
- Rollback: remove inert preparer.
- STOP: authoritative target/snapshot inputs cannot be obtained before signing, mapping is not one-to-one, or nondeterminism changes the digest.
B4 — RecoveryContract v2 provenance and tuple verification¶
- Invariant: a new contract durably records authenticated source provenance and proves its entire execution tuple matches the prepared-intent digest.
- Expected files: RecoveryContract/store schema and migration, contract builder/binding verifier, focused store/HMAC/migration tests.
- Forbidden: confirmation schema, executor authorization inputs, state transitions, MCP.
- Versions: RecoveryContract provenance version; store schema bump.
- Tests: copied IDs, altered capability/endpoint/method/target/intent/ snapshot/rollback version, tampered provenance, legacy row behavior.
- Migration: legacy rows readable for existing recovery only and never coordinator-authorized; new rows require complete provenance.
- Rollback: migration must not make v2 rows executable by older code.
- STOP: provenance cannot be covered atomically/authentically or migration would infer missing facts.
B5 — freshness and pre-consumption binding composition¶
- Invariant: all E2 gates plus authoritative execution-intent reconstruction and exact v2 binding succeed before consumption.
- Expected files: coordinator-side binding verifier/composition and focused isolation/adversarial tests.
- Forbidden: contract creation, confirmation, executor invocation, MCP.
- Versions: only v2 authorization eligible.
- Tests: every mismatch leaves authorization unconsumed; stale target/ preparer version, wrong plan/step/digest, caller substitution.
- Migration: v1 fail-closed denial.
- Rollback: E2-only coordinator remains safe.
- STOP: binding verification requires caller assertion or changes E1/E2 ordering/semantics.
B6 — ADR-024 E3 retry with bound creation¶
- Invariant: only after all gates and atomic consumption, exactly one coordinator-built matching contract can be created, prepared, confirmed, and passed once to the sealed executor.
- Expected files: execution coordinator, behavioral/isolation tests, minimum ADR-024 implementation status.
- Forbidden: executor/state-machine behavioral changes, confirmation weakening, production construction, MCP WRITE, target-identity substitute.
- Versions: consumes only v2 authorization and v2 contract binding.
- Tests: original E3 matrix plus consume→create crash window, concurrency, response loss, exact contract/confirmation/executor identity.
- Migration: no legacy authorization/contract path.
- Rollback: path remains production-unreachable; consumed authorizations are never restored.
- STOP: any need for executor authorization awareness, state-machine bypass, retry invention, or appliance identity claim.
STOP conditions¶
Implementation must stop if:
- the prepared tuple cannot be derived from one authoritative component;
- any execution-critical value remains an unchecked caller choice;
- canonical ownership or digest domain is duplicated or ambiguous;
- a v1 authorization or legacy contract could become executable by inference;
- execution freshness cannot deterministically reproduce the signed digest;
- contract provenance/tuple cannot be persisted atomically and authenticated;
- target identity must be treated as solved;
- MutationExecutor must inspect authorization;
- state-machine semantics must be bypassed;
- production construction or MCP WRITE exposure is required.
Resolved owner decisions¶
| Decision | Accepted option | Consequences | Status |
|---|---|---|---|
| Binding architecture | Alternative F: prepared intent + signed per-step digest + contract provenance | singular derivation and durable transitive proof | Resolved |
| Authorization versioning | PlanAuthorization v2 only; v1 never execution-eligible | fail-closed migration | Resolved |
| Plan integration | keep PlanDigest/PlanStep v1; bind execution in authorization v2 | preserves E1 and avoids planner duplication | Resolved |
| Contract persistence | authenticated provenance fields and the minimum schema-v6 → schema-v7 migration | durable tuple proof; no inferred legacy data | Resolved |
| Prepared snapshot scope | include snapshot and rollback-plan version in execution digest | recovery policy is authorized with mutation | Resolved |
| Legacy contracts | existing recovery only; never coordinator-authorized | no invented provenance | Resolved |
| Adapter selection | sealed exact first-WRITE registry entry | removes post-authorization substitution | Resolved |
| Expiry relationship | contract creation/confirmation currentness; contract bounded by authorization; post-send safety recovery remains available | prevents stale conversion without stranding recovery | Resolved |
| Appliance identity | configured target/TLS plus observed netgate_id or pfhostid; absence/change fails closed |
closes cross-appliance substitution for enabled deployments | Resolved |
Acceptance starts no implementation. W1 requires separate owner authorization.
Consequences¶
The design adds deliberate schema/version work before E3, but creates a reviewable cryptographic chain without moving authorization into the executor or duplicating state-machine behavior. It preserves E1 plan freshness, E2 one-time consumption, existing confirmation, and sealed execution. It makes legacy ineligibility explicit and keeps the appliance-identity gap visible.