ADR-037: WRITE Programme Architecture — Capability Families, Existence-Transition Executor, and Risk Scaling¶
- Status: Accepted (2026-09-04, owner) — architecture-only. This ADR authorizes no new WRITE capability, no executor code, and no adapter; it freezes the programme's architecture (capability-family model, execution-shape classification, existence-transition executor design, risk tagging) for future, separately-gated implementation work. See "Acceptance record" below for the clean review that led to this status change.
- Date: 2026-09-04 (drafted, then amended twice, same day: once per an owner architecture re-review, once per a follow-up narrow amendment pass correcting that review's own findings, then accepted following a clean acceptance review — see "Amendment record" and "Acceptance record" below.)
Context¶
ADR-036 closed a specific, historically-scoped body of work: four
architecture gaps in the machinery the one already-shipped WRITE
capability (set_firewall_alias_description_v1) depends on, plus (this
session) a runtime-wide pfREST Read Only gate. Its own text is explicit
that it does not authorize a second capability, and does not decide
whether WRITE scope should expand at all — "an explicit, separate
product decision this design mission does not make."
The owner has since stated a programme-level destination: eventually
account for, and where safe implement, the complete pfREST mutation
surface — not as one mission, not by bypassing ADR-036, preserving the
existing discipline (explicit authorization, narrow named capabilities,
independent review, exhaustive tests, LAB qualification where
appropriate). This ADR is the architecture layer that destination
requires before any second capability is built. The full supporting
analysis (re-derived 451-operation inventory, classification, capability
families, risk tags, postcondition/recovery feasibility) lives in the
companion report, reports-ai/WRITE_PROGRAMME_ARCHITECTURE_2026-09-04.md
(gitignored, external, mirroring how ADR-036 references its own
companion threat-model report).
Two findings from that analysis drive this ADR's decisions:
- Privilege atomicity.
security_privileges.py's existing, already-acceptedresolve_privilege()resolves 450 of the 451 mutating operations to their own distinct, narrow pfSense privilege string. Grouping operations into project-level "capability families" is therefore a pure policy layer this project fully controls — it never has to fight or widen what pfSense itself would enforce. - The existence-transition gap.
MutationExecutor.execute()/rollback()'s verification flow requires the pre-mutation authoritative read to find exactly one existing match — a hard requirement for the shapefirewall/alias.descrhas (mutate a field on a persistently-existing object). There is no way for the currentCapabilityAdaptercontract to represent "confirmed absent" as a legitimate observation rather than a refusal. The owner's architecture review (below) replaced the original rough "POST/DELETE ≈ 299 ops" estimate with an exact, method-independent classification, corrected a second time in a follow-up amendment pass after six individual operations were found misclassified by their HTTP method/path shape alone (verified against each operation's actual OpenAPI description text): 148 operations (73 create-shaped + 75 delete-shaped) genuinely need this new primitive; the remainder of what looked like create/delete by HTTP method alone turned out to be secret-lifecycle, runtime-action, non-mutating, or bulk-shaped and does not belong in this gap at all — see "Execution-shape classification" below.
Decision¶
D1 — Successor ADR, not an ADR-036 amendment¶
This ADR stands alone rather than amending ADR-036, to keep ADR-036 as the closed, auditable W0 record it already is. See the companion report's Step 3 for full rationale.
D2 — A second sealed executor for existence-transition operations, never a modification to the shared MutationExecutor¶
Per ADR-014's own "Future migration path": "If a future capability
genuinely cannot fit the single-executor model... that is a signal to
design a second, equally sealed executor variant with its own ADR — not
to weaken this executor's guarantees to accommodate an exception."
Create/delete-shaped operations are exactly that signal. This ADR
authorizes, as a future, separately-designed artifact — not
implemented here — a new spec, docs/tier1/specs/sealed_existence_executor.md,
following sealed_executor.md's own "designed, reviewed, frozen, then
implemented" discipline, before any create/delete-shaped capability
(including the DNS resolver host override candidate this programme
originally considered) is built. The existing MutationExecutor and its
one live capability are not touched by this decision, and Shape A
(existing-target update) continues to be served by MutationExecutor
exactly as it is today — this ADR does not propose migrating it.
D2.1 — Refined executor shape (owner architecture review, 2026-09-04).
The review explicitly required treating CREATE and DELETE as
independent questions rather than assuming they share one executor
merely because both fall outside today's implementation. Finding: they
are not independent in the way that matters most — each shape's
rollback is a live instance of the other shape's forward operation
(rolling back a create means deleting what was created; rolling back a
delete means re-creating what was removed, from the preserved
pre-deletion snapshot). Two fully separate, independently-implemented
executor classes would therefore either duplicate the "perform a
create-shaped send and verify it" logic inside DeleteExecutor.rollback()
and the mirror inside CreateExecutor.rollback() — exactly the
"multiplies the amount of security-critical code that must be kept
consistent across N copies" risk ADR-014 itself used to reject
per-capability executors — or import from each other, which is worse.
Recommendation: one class, ExistenceTransitionExecutor, exposing two
distinctly-named, distinctly-typed public entry points, create() and
delete() — structurally the same pattern MutationExecutor already
uses for its own two related-but-different operations, execute() and
rollback(). create() accepts only a CreationAdapter-shaped Protocol
(whose read_target() may return a typed ConfirmedAbsent sentinel);
delete() accepts only a DeletionAdapter-shaped Protocol (whose
read_target()'s post-mutation call may return that same sentinel).
Each method's own rollback path reuses the class's own other primitive
internally — never duplicated, never adapter-visible, never a second
place either primitive is implemented. This is deliberately not a
single execute(direction=...) method with a runtime discriminator flag
(rejected: a mismatched flag/adapter-shape pairing would be a real,
avoidable bug class; two distinctly-named/-typed methods make the shape
structural rather than a value a caller could get wrong).
Precondition/postcondition/binding algebra per shape (all three
share the existing store, state machine, WriteApiClient, crypto, and
canonical-digest infrastructure unchanged; contract.verify_bindings()
itself needs zero changes for any shape — it already just digests and
compares whatever canonical target_precondition value the adapter
computes, and shape B's "confirmed absent" marker is just another
canonical value to digest, the same mechanism, not a new one):
A — UPDATE (live, MutationExecutor) |
B — CREATE (ExistenceTransitionExecutor.create()) |
C — DELETE (ExistenceTransitionExecutor.delete()) |
|
|---|---|---|---|
| Canonical precondition | Fresh fingerprint of the existing object | Fresh, TOCTOU-safe proof of absence at the natural identity (canonical marker, not a real snapshot) | Fresh fingerprint of the existing object (identical to A) |
| Canonical postcondition | Fresh fingerprint shows exactly the intended field(s) changed, nothing else | Fresh read finds exactly one match whose fields equal the intended create payload; server-assigned locator bound only now | Fresh read finds zero matches (same ConfirmedAbsent sentinel as B's precondition, used at the opposite point in the flow) |
| Plan/authorization binding | Existing PlanAuthorizationV2/verify_bindings(), unchanged |
Same, unchanged — the digest just covers a different canonical value | Same, unchanged |
| Confirmation binding | Existing ConfirmationEvidence, unchanged |
Same, unchanged | Same, unchanged |
| Fresh pre-send verification | Re-read, compare fingerprint to contract | Re-read, re-confirm absence (TOCTOU: another admin could have created it since PREPARE) | Re-read, compare fingerprint to contract (identical to A) |
| pfREST Read Only gate placement | Immediately before EXECUTING, after binding verification (Mission III) — but only inside execute(); verified directly against source that MutationExecutor.rollback() does not call _require_pfrest_writable() at all today. This is presently harmless only because rollback() has zero production call sites (nothing wires it to any live MCP tool) |
Must independently call the gate before every mutating send this class can issue, including the internal reuse of create()'s primitive inside delete()'s rollback path and vice versa. Not "inherited automatically" — the pattern being mirrored (MutationExecutor) does not itself demonstrate correct rollback-path gate coverage, so nothing about it can be assumed true by imitation. Because this design makes rollback genuinely reachable for the first time (unlike today's dead rollback()), sealed_existence_executor.md must explicitly specify and test gate placement on every entry point as its own reviewed requirement, not infer it from MutationExecutor's existing shape |
Same explicit, independently-specified requirement as CREATE's column — every entry point, including inverse-transition reuse, crosses the gate before send |
EXECUTING transition placement |
After the gate, before the one send | Identical | Identical |
| Sole mutating transport boundary | One WriteApiClient.send_for_tier1() call |
One call, same client, same chokepoint | One call, same client, same chokepoint |
| Semantic verification | is_semantically_verified(pre, post, intent) |
Same signature; pre is the ConfirmedAbsent sentinel, post is the newly-read real object |
Same signature; pre is the real pre-state, post is the ConfirmedAbsent sentinel |
| Mutation-result-unknown handling | → RECONCILIATION, never blind retry |
→ RECONCILIATION; discover before retry — never blindly replay the POST (POST is not naturally idempotent; a replay could create a second object). Reconciliation resolution re-reads by natural identity (reusing the existing observe_reconciliation_target() pattern, which already re-reads by identity, not locator) — zero matches means the create did not happen; exactly one match means it did (bind its locator now); more than one is genuine ambiguity requiring manual resolution, never auto-picked |
→ RECONCILIATION. Reread first: absence proves the delete succeeded (safe to mark verified even after a transport-ambiguous send); continued existence permits only the explicitly-defined recovery path, never a blind retry; a read failure (as opposed to a clean zero-match read) must never be interpreted as absence — it raises and routes to RECONCILIATION exactly like today's read-failure handling, never conflated with ConfirmedAbsent |
| Recovery classification | FAILED (proven zero effect) / RECONCILIATION (ambiguous) — unchanged terminal states |
Same two terminals, same meanings | Same two terminals, same meanings |
| Replay rules | Never retried automatically (existing) | Never retried automatically; additionally never assumed idempotent (POST) | Never retried automatically; DELETE against an already-absent target is naturally idempotent at the transport level (typically 4xx/zero-effect), which is why C's ambiguous-handling can safely treat a clean zero-match reread as proof of success rather than needing discovery machinery |
| Idempotency assumptions | Existing derive_idempotency_key() + store uniqueness constraint, unchanged |
An in-flight or unresolved create contract for the same natural identity must be discoverable by a fresh PREPARE-time precondition check for a second contract attempt — no new mechanism, a direct consequence of "PREPARE only ever binds to a fresh read" | Unchanged from A's existing idempotency posture |
| Audit/journal semantics | store.transition()/_insert_audit(), unchanged |
Same mechanism — RecoveryState is already generic over contract lifecycle, not update-specific; no new schema |
Same mechanism |
Rollback/gate invariants (owner amendment pass, 2026-09-04), stated explicitly rather than left implicit:
- Every independently callable mutation entry point on
ExistenceTransitionExecutor—create(),delete(), and each's internal reuse of the other's primitive for its own rollback — must cross the pfREST writable gate before any network mutation. No entry point is exempt by virtue of being "internal" or "rollback." - Internal reuse of create/delete logic for rollback must not bypass that gate, or any other gate below. Rollback is not an alternate authorization path — it is the same sealed send chokepoint, reached a second time under the same rules, not a side door.
- Authorization (plan binding), confirmation binding, replay protection,
risk-class binding, endpoint binding, and privilege binding remain
independently required on every path, exactly as they already are for
MutationExecutor.execute()— none of these are satisfied "once" and then assumed to hold for a later internal reuse. sealed_existence_executor.md, when written, must specify gate placement explicitly and include a direct test proving every entry point (including the rollback-reuse paths) fails closed whenread_only=true— inheriting the placement by assumption fromMutationExecutor's shape is exactly the mistake this amendment corrects, since that shape does not itself prove full coverage (see the gate-placement table row above).
This reduces the design surface from "a wholesale new architecture" to
"one new class following an already-proven two-method pattern, one new
adapter Protocol pair, and one new sentinel type (ConfirmedAbsent)" —
still real, still security-critical, still not implemented here.
D2.2 — Execution-shape classification (owner architecture review, re-derived, not assumed from HTTP method)¶
Per the review's explicit instruction not to assume POST == CREATE,
DELETE == simple deletion, or PATCH/PUT == ordinary update: every one
of the 451 operations was re-classified by inspecting its actual path
semantics (secret/credential involvement and runtime-action framing
checked before falling through to a method-informed shape), independently
of the companion report's original cluster-level pass.
Corrected in a follow-up amendment pass (2026-09-04) after the first classification pass's own numbers were themselves found to contain six misclassified operations — caught only by reading each operation's actual OpenAPI description text, not by the script's path/method heuristics alone:
DELETE /diagnostics/arp_table(bare) andDELETE /system/restapi/access_list: both take onlylimit/offset/queryparams, noid— genuinely bulk/filtered deletes ("Deletes multiple existing ARP Table Entries using a query"), moved from Shape C into the bulk bucket. The classifier's original plural-path-suffix heuristic missed both since neither path segment is plural-spelled; corrected to key on parameter shape (presence of anid) instead, which is the semantically correct signal and was verified against every Shape C row.DELETE /diagnostics/table: has anidparam but its description reads "Flushes all entries in a specified table. Please note this does not delete the table itself, only its entries" — a runtime action on live/volatile contents, not a delete of the config resource itself; confirms the original classification, now on real evidence rather than a path-keyword guess.DELETE /firewall/state(bare, singular): description explicitly states "the firewall state table changes very quickly which may result in the state'sidsuddenly changing... use caution" and recommends the bulk/query endpoint instead — targets live connection-tracking state whose identity can change independent of any API action, which structurally breaks Shape C's TOCTOU precondition-rebinding model for this specific resource. Moved to runtime/action shape.POST /status/service: description reads "Triggers a start, stop or restart action for an existing Service" — a runtime action despite the POST method. Moved from Shape B to runtime/action.POST /system/enum: description reads "Enumerate all possible choices for a given model field" — not a mutation at all despite appearing in the mutating-methods inventory; a read-like introspection query the schema happens to expose via POST. Moved out of Shape B into a new, explicit "does not fit" sub-reason (non-mutating anomaly, not bulk-shaped) rather than force-classified into any A/B/C/runtime/secret bucket.POST /vpn/openvpn/client_export(bare): description reads "Export an OpenVPN Client configuration" — distinct from its sibling/client_export/config(a stored export-preferences object, ordinary CRUD); this bare endpoint is the action that actually emits the client bundle, which for OpenVPN client export includes the client's private key and certificate. Moved from Shape B to credential/secret-lifecycle.
Full corrected dataset: mutation_universe_execution_shapes.json
(scratchpad), produced by the corrected classification script with each
override individually justified in code comments, not merely asserted.
| Execution shape | Count | Notes |
|---|---|---|
| A — existing-target update | 100 | PATCH, plus non-bulk single-object PUT — unaffected by the correction pass |
| B — create/absent-to-existing | 73 | POST that is genuinely a create, secret/runtime/non-mutating exclusions removed (net −3 from the correction pass: status/service, system/enum, vpn/openvpn/client_export bare) |
| C — delete/existing-to-absent | 75 | DELETE on a single, identified object, verified to have a genuine id-shaped parameter for every row (net −3: two moved to bulk, one moved to runtime/action) |
| Runtime/action shape | 25 | apply triggers (8), halt_system/reboot/package*/update (5), ping/wake_on_lan (2), the one restapi/settings/sync HA-sync trigger (1), OpenVPN kill-connection/CARP-maintenance-mode/config-history-revert (3), diagnostics/table flush (1), firewall/state kill (1, corrected), status/service start/stop/restart (1, corrected) — net +2 |
| Credential/secret-lifecycle shape | 69 | certificate*/certificate_authority*/crl* (22), user*/auth_server* (14), auth/key/jwt (4), FreeRADIUS user accounts (5), HAProxy frontend certificate bindings (4), ACME certificate* actions (13) and account_key* (6), OpenVPN client-export action (1, corrected) — net +1 |
| Does not safely fit any currently proposed executor | 107 | Bulk-delete-all (73, net +2: arp_table bare + restapi/access_list bare corrected in) + bulk-collection-replace PUT (33) + non-mutating schema anomaly (1, system/enum, corrected in) — structurally excluded by ADR-014/sealed_executor.md's own G4 invariant ("no adapter-driven loops... every method signature takes one target/one intent") for the bulk subset, and simply out of scope for the non-mutating anomaly; a bulk-operation architecture is out of scope for both MutationExecutor and the proposed ExistenceTransitionExecutor |
| Rejected/redundant/source-unsupported | 2 | graphql (REJECT_GENERIC_DISPATCH), diagnostics/command_prompt (REJECT_COMMAND_EXECUTION); no SOURCE_UNSUPPORTED operations found this pass (all 451 resolved cleanly against the cached schema); no REDUNDANT operations positively identified at this pass's granularity (recorded as an incompleteness, not assumed empty) |
| Total | 451 | Every operation accounted for exactly once at this layer; risk/family tags (D3/D5 below) remain orthogonal, layered on top, never double-counted here |
This supersedes both the original companion report's rougher "POST+DELETE ≈ 299 operations, ~66%" estimate and the first (uncorrected) classification pass's 154-operation figure: the verified count of operations that actually need the new existence-transition primitive is 148 (73 + 75, 32.8% of the inventory) — update+create+delete together account for 248 operations (55.0%) of the full 451. A materially smaller, more precisely scoped gap than the original rough estimate, because a large share of what looked like create/delete by HTTP method alone is actually secret-lifecycle, runtime-action, non-mutating, or bulk-shaped and was never going to fit a two-shape executor regardless of how CREATE/DELETE themselves were designed.
The 450/451 privilege-resolution exception, independently re-verified:
POST /system/restapi/settings/sync is the sole operation with no
narrow pfSense privilege (requires page-all only). It classifies as
runtime/action shape, not create-shaped, confirming it was never a
candidate for ExistenceTransitionExecutor.create() in the first place —
this operation remains excluded at the Capability enum level (D4)
regardless of which executor architecture is eventually built.
D3 — Capability families replace "write_protected == every implemented WRITE privilege"¶
write_protected must never default to granting every implemented WRITE
family. An installation grants an explicit, named subset of families
(see the companion report's Step 5 table). No pfSense-side privilege
constraint is weakened or widened by this grouping (the privilege-
atomicity finding, Context above). Capability/MutationPolicy/MutationRule require no new
mechanism — only new rule population per family as each family's
adapters are actually built. A family is a policy/least-privilege
grouping only, never itself an authorization mechanism: membership in
a granted family determines whether MutationPolicy.authorize() has a
matching rule at all, but every other gate downstream of that — sealed
plan/confirmation binding, the fresh precondition re-read, the pfREST
Read Only gate, semantic postcondition verification — still runs in
full for every single call, exactly as it does today for alias_write;
no family, however broadly granted, skips or weakens any of these.
Secret-lifecycle and structural exclusions cannot be overridden by a
broad package/cluster name (owner amendment pass, 2026-09-04). A
family such as service_settings_write may legitimately span an entire
top-level product/package cluster (e.g. FreeRADIUS, HAProxy, ACME) for
its ordinary settings operations, but operation-level exclusion always
wins over cluster-level family membership: services/freeradius/user*,
services/haproxy/*/certificate*, services/acme/certificate*, and
services/acme/*account_key* remain permanently excluded from
service_settings_write (or any family) even though freeradius,
haproxy, and acme are otherwise legitimate family members — see D4's
now-explicit enumeration. Bulk operations (D2.2's "does not fit" bucket)
remain outside the family architecture entirely regardless of which
cluster they belong to, for the same reason they're outside the
executor architecture (ADR-014/sealed_executor.md G4). Runtime/action
operations are independently classified from both update/create/delete
shapes and from families — a family granting, say, dns_write does not
imply anything about the services/dns_resolver/apply runtime-action
trigger, which is a distinct classification requiring its own separate
authorization decision if ever built.
D4 — Permanent exclusions are never assignable to any family¶
system/certificate*/certificate_authority*/crl*, user/*,
auth/*, diagnostics/command_prompt, graphql,
system/package*/update/restapi/access_list/restapi/settings/sync,
diagnostics/halt_system/reboot remain excluded at the Capability
enum level entirely — never merely left out of a default grant. This
matches the existing WriteEndpoints-exact-catalogue enforcement
pattern (scripts/write_allow_list_check.py) and ADR-036's own
non-negotiable invariant list.
Extended (owner amendment pass, 2026-09-04) to close a gap the
earlier list left open: it named only the system/, user/, auth/
prefixed secret-lifecycle operations, but D2.2's own classification
includes 28 further secret-lifecycle operations sharing a top-level
cluster name with an otherwise-legitimate family. Explicitly also
excluded, regardless of service_settings_write or any other family's
otherwise-legitimate claim to the same top-level cluster:
services/freeradius/user/users (5 operations — RADIUS account
credentials); services/haproxy/frontend/certificate/certificates
(4 operations — TLS certificate bindings); services/acme/certificate/
certificate/action/certificate/domain/certificate/issue/
certificate/renew/certificates (13 operations); services/acme/
account_key/account_key/register/account_keys (6 operations — the
ACME account's own private key). vpn/openvpn/client_export (bare, the
credential-emitting export action specifically, not its sibling
/client_export/config preferences-object CRUD) is likewise excluded.
D5 — Risk classification gets a second, explicit dimension¶
AuthorizationLevel (confirmation friction) remains unchanged and
correct as-is. A new, per-family (not per-operation) failure-mode tag
set is added for architecture/sequencing decisions: Lockout, Routing
loss, DNS/DHCP outage, Firewall exposure, Credential compromise, VPN
disruption, Cert/key destruction, Service interruption (see companion
report's Step 6 table). No family's ceremony may be weaker than
alias_write's already-live one. Families tagged Lockout or Routing
loss require a documented recovery path that does not itself depend on
the management channel the operation could sever, before
implementation — not designed by this ADR, recorded as a prerequisite.
Non-negotiable invariants preserved by this decision¶
Overlapping with, and extending, ADR-036's own list — not literally
identical (corrected, owner amendment pass, 2026-09-04). The prior
draft of this section claimed identity with ADR-036's list; that
overclaimed precision. ADR-036's list additionally names two items this
list does not restate verbatim: read_only default posture and managed
read_only least privilege (both are properties of the default READ-only
profile, orthogonal to this WRITE-programme ADR, and remain untouched by
it) and "optional stronger TPM/witness assurance" — this one is
directly WRITE-relevant (ADR-036 W0 Gap 1 ties risk_class enforcement
to required anchor assurance) and is not restated here by omission, not
by design: this ADR does not alter, weaken, or remove TPM/witness
assurance in any way. It remains exactly as ADR-036 left it — an existing,
optional, AnchorAssurance-graded mechanism, constructor-injected into
whichever executor(s) are built (MutationExecutor today,
ExistenceTransitionExecutor in the future, per its own
anti_rollback_anchor parameter, unchanged) — and D5's risk-tagged
families (particularly Lockout/Routing-loss-tagged ones) are exactly the
kind of family where a future, separately-authorized decision to require
hardware_witness assurance would be the natural extension of ADR-036's
own W0 Gap 1 pattern. This ADR does not make that decision; it does not
need to, and does not foreclose it either.
Zero
default-reachable WRITE; no generic HTTP/path dispatcher (graphql
permanently REJECT_GENERIC_DISPATCH); no arbitrary shell/command
capability (diagnostics/command_prompt permanently
REJECT_COMMAND_EXECUTION); explicit, reviewed Capability/
WriteEndpoints catalogue only; canonical endpoint/method derivation;
canonical risk-class derivation (D5); canonical privilege derivation
(resolve_privilege(), the privilege-atomicity finding); sealed
authorization; confirmation binding; no MCP caller/agent can select
which endpoint, HTTP method, privilege, risk class, or executor
(MutationExecutor vs. ExistenceTransitionExecutor.create()/
.delete()) a given call reaches — each registered MCP tool is wired,
at project registration time, to exactly one specific adapter and
exactly one specific executor entry point, identically to how
alias_write's one tool is wired today; nothing about a caller's
arguments ever chooses that binding; the pfREST Read Only owner gate (this session's Mission III,
inherited automatically by every future capability since it lives in
the shared executor(s), not per-adapter); precondition/fingerprint
verification; semantic postcondition verification; bounded recovery;
fail-closed transport-ambiguity handling; least privilege. None of these
are weakened, extended, or reinterpreted by this ADR.
Consequences¶
Positive¶
- A concrete, falsifiable classification exists for all 451 currently-known mutating operations — zero left unclassified — replacing "chase WRITE coverage" with a reviewable, bounded programme.
- The existence-transition gap is identified and named before any code is written against it, rather than discovered mid-implementation of a real capability.
- Capability families give installations genuine least-privilege choice
once many WRITE capabilities exist, rather than an all-or-nothing
write_protectedtoggle.
Negative¶
- This ADR authorizes no new capability itself — real WRITE surface
growth still requires, per family/operation, the same one-capability-
at-a-time rigor
firewall/alias.descrreceived (own mission, own LAB qualification, own adversarial test matrix). - The existence-transition executor is real, non-trivial, security-critical design work not yet done — 148 operations (32.8% of the inventory, see D2.2) remain blocked on it, not on risk classification alone.
Alternatives considered¶
- Extend
MutationExecutor.execute()in place with atarget_lifecyclebranch for create/delete: rejected — directly contradicts ADR-014's own explicit guidance not to weaken the shared executor's guarantees to accommodate a shape it wasn't designed for, and would touch the live capability's own code path for no benefit to that capability. - Amend ADR-036 instead of a successor ADR: rejected — would blur ADR-036's own closed, historically-scoped W0 record (see companion report Step 3).
write_protectedgrants everything implemented: rejected — becomes unsafe and unreviewable once dozens of families exist; no privilege-atomicity reason requires it.- Two fully independent executor classes,
CreateMutationExecutorandDeleteMutationExecutor, sharing no code: rejected — each shape's rollback is a live instance of the other shape's forward operation, so full independence would force either duplicated create/delete-send logic (a real, avoidable security-critical duplication risk) or one class importing the other, which is worse. See D2.1. - One executor with a runtime
direction: CREATE | DELETEdiscriminator instead of two named methods: rejected — a mismatched discriminator and adapter shape is an avoidable bug class;create()/delete()as distinctly-named, distinctly-typed methods make the shape structural, mirroringMutationExecutor's own existingexecute()/rollback()pattern rather than inventing a new one. See D2.1.
Amendment record¶
Amended 2026-09-04, same day as the initial draft, following an owner architecture review of this Proposed ADR. Status remains Proposed — the review explicitly declined to accept it. Changes made:
- D2 refined from a provisionally-named, unspecified "second sealed
executor" into a concretely-designed
ExistenceTransitionExecutorwith namedcreate()/delete()methods, full precondition/ postcondition/binding algebra per shape (D2.1), and explicit ambiguous-result/replay/idempotency rules distinguishing CREATE (discover-before-retry, not idempotent) from DELETE (reread-first, naturally idempotent, read-failure ≠ absence). - D2.2 added: exact execution-shape classification (not HTTP-method-based) replacing the original ~66%/299-operation estimate with a precise 154-operation (34%) figure for what genuinely needs the new executor, and separating out runtime-action (23), secret-lifecycle (68), and bulk-shaped-and-out-of-scope (104) operations that had been folded into the rougher estimate.
- Two additional rejected alternatives recorded (two independent executor classes; a runtime direction-discriminator flag).
- Context section's gap-size claim corrected to match D2.2.
No change to D1, D3, D4, D5, or the non-negotiable invariants list in this first pass — the owner re-review that followed this amendment found concrete, fixable issues in exactly those sections, addressed in the second amendment pass below.
Second amendment pass (2026-09-04, narrow, following the owner acceptance re-review)¶
The re-review's decision was REVISE, not ACCEPT, with a minimum required amendment set. All five items were addressed:
- D2.1 pfREST gate-inheritance claim corrected. Verified directly
against
executor.pysource thatMutationExecutor.rollback()does not call_require_pfrest_writable()(onlyexecute()does) — presently harmless only becauserollback()has zero production call sites. The prior "identical placement, inherited automatically" wording is replaced with an explicit statement that this must be independently specified and tested for everyExistenceTransitionExecutorentry point, not assumed fromMutationExecutor's own shape. Five explicit rollback/gate invariants added. - D4 secret-lifecycle exclusions extended to name all 28 previously-
unlisted operations (
services/freeradius/user*,services/haproxy/*/certificate*,services/acme/certificate*/*account_key*) and D3 gained explicit wording that operation-level exclusion always wins over cluster-level family membership, even for families spanning an entire product/package (FreeRADIUS/HAProxy/ACME). - Stale "~2/3"/"roughly two-thirds" figures corrected in Context,
Consequences > Negative, and (separately)
docs/adr/README.md's index row, to match D2.2's verified 148-operation/32.8% figure. - "Identical to ADR-036's own list" corrected to acknowledge overlap-not-identity, and TPM/witness assurance explicitly addressed (unchanged, out of scope for this ADR, remains available for a future risk-tagged-family decision per ADR-036's own W0 Gap 1 pattern).
- Classification defects corrected at the data level, not merely
caveated: re-inspecting each flagged operation's actual OpenAPI
description text (not just path/method shape) found and fixed six
misclassified operations (
diagnostics/arp_tablebare,system/restapi/access_listbare,firewall/statebare,status/service,system/enum,vpn/openvpn/client_exportbare). The classifier's DELETE bulk-detection rule was corrected from a plural-path-suffix heuristic to the semantically correct signal (presence of anid-shaped parameter), verified against every Shape C row (zero remaining rows lack anid). D2.2's full table, the 148-operation gap figure, and every dependent count/percentage were re-derived from the corrected dataset, not patched by hand.
No further amendment required to D1 or D5 — the re-review found no fault with either.
Acceptance record¶
2026-09-04, owner. A clean acceptance review (not another exploratory
pass) independently re-read this ADR in full, ADR-036's relevant
invariants, ADR-014's sealed-executor-evolution language, current
MutationExecutor.execute()/rollback(), the AnchorAssurance/
anti_rollback_anchor interfaces, and the corrected classification
evidence — verifying every claim against source rather than trusting the
prior amendment passes' own summaries. One editorial-only issue found and
fixed (a stray "diagnostics/" prefix on two firewall/state references
in D2.2/Amendment record — not a normative claim, corrected narrowly).
All 26 stated acceptance criteria passed on independent verification,
including: the 451-operation total, the 100/73/75/25/69/107/2
execution-shape counts, the 148-operation/32.8% CREATE+DELETE gap, the
248-operation/55.0% update+create+delete total, correct bulk/runtime
classification of /diagnostics/arp_table and /diagnostics/table,
secret-lifecycle exclusion completeness and its precedence over
cluster-level family membership, the pfREST Read Only gate invariant's
explicitness for every future ExistenceTransitionExecutor entry point
(including rollback-reuse), rollback's status as the same sealed
chokepoint rather than an alternate authorization path, preserved
TPM/hardware-witness assurance (independently confirmed against
security_discovery.py's real AnchorAssurance.HARDWARE_WITNESS), and
ADR-036's historical W0 scope remaining fully intact and untouched. No
genuinely blocking contradiction was found. Status changed from Proposed
to Accepted on this basis — architecture only, per the Status line
above; every deferred item (sealed_existence_executor.md, per-cluster
schema qualification, bulk-operation architecture, lockout-safe recovery
design, and any real capability's own LAB qualification) remains exactly
as deferred as it was before acceptance, each requiring its own separate
authorization before implementation.
References¶
- ADR-014
- ADR-033
- ADR-036
reports-ai/WRITE_PROGRAMME_ARCHITECTURE_2026-09-04.md(gitignored, external — the full inventory/classification/family/risk analysis this ADR summarizes)reports-ai/POST_READ_CLOSURE_WRITE_ARCHITECTURE_THREAT_MODEL.md(gitignored, external — ADR-036's own companion report, re-verified and extended by this ADR's companion report)