diff --git a/crates/provisioning/Cargo.toml b/crates/provisioning/Cargo.toml
index 9cb4de0..70a8976 100644
--- a/crates/provisioning/Cargo.toml
+++ b/crates/provisioning/Cargo.toml
@@ -9,4 +9,5 @@ publish = false
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
+parking_lot = "0.12"
serde_json = "1"
diff --git a/crates/provisioning/README.md b/crates/provisioning/README.md
new file mode 100644
index 0000000..5c2c328
--- /dev/null
+++ b/crates/provisioning/README.md
@@ -0,0 +1,226 @@
+# provisioning — the cluster reconciler
+
+This crate drives a declared cluster shape toward convergence. A caller states
+*what the run should look like* — which node groups, how many of each, with what
+provider shape and boot parameters — and the reconciler repeatedly compares that
+desired shape against observed reality, taking the next safe step for each node
+until the two match. It is modeled on Kubernetes controller mechanics
+(level-triggered decisions, spec/status separation, workqueue-style coalescing,
+finalizer-style deletion) but runs entirely in-process over the Swactor/Myelin
+engine: there is no API server, and no persistence beyond the process lifetime.
+
+The payoff over the imperative lifecycle it replaced: the system converges from
+whatever state it is currently in. A reconcile pass is a pure function of
+`(observed, desired, now)` — never of the event that triggered it — so missed
+events, duplicated events, and crash-of-a-single-pass all heal on the next
+pass. Reconcile is a function of state, not events.
+
+## The three roles
+
+One state-ownership rule upholds the design: **only the driver mutates observed
+state.**
+
+| Role | Embodiment | Responsibility |
+|---|---|---|
+| Decider | `reconcile` / `reconcile_node` | Pure, deterministic: reads state snapshots, returns next actions. No I/O, no clocks, no randomness. |
+| Driver | `ClusterDriver` | Sole writer of `ClusterState`. Folds observations, coalesces triggers, runs passes, records operations as pending *before* dispatch, schedules requeues. |
+| Executor | `EffectExecutor` / `IdempotentEffectExecutor` + a provider `EffectBackend` | Runs provider I/O off the pass, deduplicates by operation identity, adopts resources after ambiguous outcomes. |
+
+## Inputs
+
+**Desired state** — `ClusterShape { run_id, generation, groups }`. It expands to
+one `LogicalNodeSpec` per slot named `{group_id}-{index}`; validation rejects
+cross-run groups, duplicate group or node IDs, and non-finite shape values.
+The driver additionally enforces a revision contract: the run ID is fixed for
+its lifetime, `generation` must strictly increase whenever shape content
+changes, and changed content at the same generation is rejected. A node's spec
+is an immutable attempt template — any drift (image, boot, role, provider,
+swarm-join) means *replace the attempt*, never mutate it in place.
+
+**Observed state** — `ClusterState`: the last-evaluated generation, a monotonic
+attempt-ID allocator, and one `ManagedNode` per logical slot. A `ManagedNode`
+carries its attempt ID, intent (`Active` / `Deleting`), the lifecycle-fact
+record (`NodeRecord`), at most **one** pending operation, and per-node retry
+state. Identity is layered: a `LogicalNodeId` is the stable slot;
+a `NodeAttemptId` names one incarnation of that slot (like a k8s object name
+vs its UID); an `OperationId` (attempt + sequence) names one dispatched effect.
+Results from an old attempt can never mutate a newer one.
+
+**Events** — executor results, bootstrap stream observations, timeouts, and a
+periodic tick. Events carry no decision input; they only mark the cluster
+dirty and are folded into observed state before the next pass looks.
+
+## Reconciliation flow
+
+Per node, progress is a ladder of stages crossed by one effect at a time, with
+a deletion track that runs to completion once entered:
+
+```mermaid
+stateDiagram-v2
+ [*] --> New : Insert (desired slot, fresh attempt)
+ New --> LeaseRequested : Dispatch CreateLease
+ LeaseRequested --> LeaseCreated : lease observed, endpoint unknown
+ LeaseRequested --> EndpointKnown : lease + endpoint observed
+ LeaseCreated --> EndpointKnown : LookupEndpoint succeeds
+ LeaseCreated --> LeaseCreated : LookupEndpoint : not ready yet (probe)
+ EndpointKnown --> BootstrapRunning : StartBootstrap accepted
+ BootstrapRunning --> BootstrapRunning : bootstrap stream observations
+ BootstrapRunning --> SwactorJoined : swactor joins the swarm
+ SwactorJoined --> Dormant : convergence observed / bootstrap closed
+ Dormant --> [*] : ready steady state
+
+ LeaseRequested --> LeaseRequested : CreateLease fails (backoff, retry)
+ EndpointKnown --> Failed : bootstrap fails to start
+ BootstrapRunning --> Failed : bootstrap runtime / join failure
+ SwactorJoined --> Failed : bootstrap closes before convergence
+
+ Failed --> Deleting : BeginDelete (next pass)
+ New --> Deleting : BeginDelete (slot undesired or spec drifted)
+ Dormant --> Deleting : BeginDelete (scale-down / replacement)
+
+ Deleting --> Deleting : CancelBootstrap → DestroyLease (in order)
+ Deleting --> Destroyed : cleanup complete (MarkDestroyed)
+ Destroyed --> [*] : Reap (slot undesired)
+ Destroyed --> New : Restart after restart_at backoff (still desired)
+```
+
+A pass picks **at most one action per node**; a driver transition
+(`Insert`, `BeginDelete`, `MarkDestroyed`, `Restart`, `Reap`) completes that
+node's step, and its follow-on effect is considered in a later pass. Nodes
+progress independently — one node's slow provider I/O never blocks another.
+
+### The per-pass decision ladder
+
+For each node, the decider's rules in priority order (first match wins):
+
+| # | Condition | Action |
+|---|---|---|
+| 1 | stage `Destroyed`, slot undesired | `Reap` — remove from the map |
+| 2 | stage `Destroyed`, slot desired, `restart_at` due | `Restart` — fresh attempt, latest spec |
+| 3 | stage `Destroyed`, restart backoff not due | wait until `restart_at` |
+| 4 | intent `Active` and (undesired, spec drift, or stage `Failed`) | `BeginDelete` |
+| 5 | an operation is pending | wait for its result or stored deadline |
+| 6 | intent `Deleting`, ambiguous create/bootstrap remembered | re-dispatch that create (executor adopts) |
+| 7 | intent `Deleting`, active bootstrap session | `CancelBootstrap` |
+| 8 | intent `Deleting`, lease still live | `DestroyLease` |
+| 9 | intent `Deleting`, nothing left to clean | `MarkDestroyed` |
+| 10 | retry backoff (`next_effect_at`) not due | wait |
+| 11 | ready in `HandedOff` / `Dormant` | none — steady state |
+| 12 | no lease | `CreateLease` |
+| 13 | lease but no SSH endpoint | `LookupEndpoint` |
+| 14 | stage `SwactorJoined` with live session | `BootstrapConvergenceObserved` |
+| 15 | bootstrap running, awaiting observations | none — await stream events |
+| 16 | lease + endpoint, no bootstrap session | `StartBootstrap` |
+
+Rows 1–4 handle topology (scale up is an `Insert` seen before row 1); rows
+5–10 handle in-flight work and deletion; rows 11–16 are the healthy
+progression ladder. Cleanup ordering is deliberately sequential — cancel
+bootstrap, then destroy the lease, then mark destroyed — so partial success is
+never ambiguous.
+
+## Triggers and requeues
+
+The driver is the process-local equivalent of a single-key Kubernetes
+workqueue: one pass runs at a time (reentry is an error), triggers while
+queued collapse, and a trigger during a pass marks dirty and guarantees exactly
+one follow-up pass.
+
+| Trigger | Source | Effect |
+|---|---|---|
+| Desired shape update | `update_desired` (validated, generation advanced) | queue a pass |
+| Executor result | operation completed / failed | fold observation, queue a pass |
+| Bootstrap observation | stream stage, swactor join, closure, failure | fold observation, queue a pass |
+| Operation timeout | stored pending-operation deadline | fold as ambiguous failure, queue a pass |
+| Retry / probe / restart deadline | `trigger_if_due(now)` against `requeue_at` | queue a pass |
+| Periodic wake | host tick (safety net, not the progress mechanism) | queue a pass if due |
+
+Every pass recomputes `requeue_at` as the earliest deadline among waiting
+nodes (pending-operation deadlines, backoff, probes, restarts). The host
+(`apps/myelin`'s `ProvisionedClusterGuard`) drives `drive_until_blocked` on
+each wake and re-arms the timer.
+
+## Node conditions
+
+`NodeStage` is the observation ladder; `ready` is the convergence flag:
+
+| Stage | Meaning |
+|---|---|
+| `New` | slot inserted, nothing dispatched yet |
+| `LeaseRequested` | `CreateLease` dispatched, pending |
+| `LeaseCreated` | provider lease exists; SSH endpoint not yet known |
+| `EndpointKnown` | lease + reachable SSH endpoint recorded |
+| `BootstrapRunning` | bootstrap session started; stream observations flowing |
+| `SwactorJoined` | the node's swactor joined the swarm |
+| `HandedOff` | host marked handoff complete (reserved; the ready-check accepts it) |
+| `Dormant` | bootstrap finished, handoff recorded — **ready** steady state |
+| `Failed` | attempt-ending failure recorded (`failed_reason`, `failed_at`) |
+| `Destroyed` | cleanup finished; awaiting reap or restart |
+
+Bootstrap internals (`BootstrapStage`: SSH connect, boot check, swactor start,
+join, converged, plus five failure stages) are facts folded into the record;
+they update progress but the reconciler only branches on their failure/converged
+classes, never on individual stream events.
+
+## Failure and backoff
+
+Not every failed call kills an attempt. Classification by operation:
+
+| Failure | Retained state | Behavior |
+|---|---|---|
+| `CreateLease` | nothing | retry after exponential backoff |
+| `LookupEndpoint` (not ready) | lease | re-probe on probe interval — not a failure |
+| `LookupEndpoint` (error) | lease | retry after backoff |
+| `StartBootstrap`, bootstrap runtime, or join | facts for observability | **attempt fails**: cleanup starts immediately; backoff applies to the *restart*, not the cleanup |
+| `CancelBootstrap` / `DestroyLease` | stay in `Deleting` | retry after backoff |
+| any timeout / ambiguous create | remembered | retry re-issues the same create so the executor adopts first |
+
+Backoff is per node: exponential from 1 s to a 60 s cap (defaults), with
+optional jitter sampled *deterministically* from the attempt ID — the decider
+never reads randomness or a clock. Deadlines are computed once when an
+observation is folded and stored; the pure decider only reads them. Reaching
+ready resets the failure count. A failed attempt's `consecutive_failures`
+carries into its replacement so hot-restart loops still back off.
+
+## What is guaranteed
+
+| Class | Guarantee |
+|---|---|
+| Determinism | Identical traces converge to identical state; execution order of independent work doesn't matter; a pass over a settled machine is a no-op. |
+| Attempt isolation | Attempt IDs are never reused; results and facts from a superseded or retired attempt are discarded, never folded or leaked into a replacement. |
+| No unrecorded effects | Every effect is recorded as pending before submission; one pending operation per node, one running per attempt; a destroyed node holds no lease, session, or pending operation; failed cleanup keeps its live facts. |
+| Failure classification | Per-node backoff with one stored deadline; attempt failure cleans up immediately and delays only the restart; endpoint-not-ready is a probe, not a failure; ambiguous outcomes adopt before any destructive step; exhaustion and clock saturation are errors, never spins or panics. |
+| Bounded convergence | Converges to the latest desired shape — intermediate generations may be skipped — in bounded rounds once faults stop; scale-down removes only highest-index slots; replacement starts a fresh attempt only after full cleanup; generation regressions and silent shape changes are rejected. |
+
+## Operation identity and idempotency
+
+A deterministic plan is not by itself a safe side effect; safety comes from the
+identity contract:
+
+- The driver records an operation as pending **before** dispatch and never
+ emits a second operation for a node while one is pending. If submission
+ itself fails, that folds as an operation failure — no unrecorded in-flight
+ effect is ever observable.
+- The executor deduplicates by `OperationId`: resubmitting a completed
+ operation replays its recorded result; reusing an ID with different input is
+ rejected; at most one operation runs per attempt at a time.
+- Provider backends must key external requests on
+ `(run_id, logical_node_id, attempt)` and **adopt** an existing resource for
+ that identity before creating anew; cancel/destroy treat "already absent" as
+ success.
+- Timeouts expire an operation as *ambiguous* only after the executor
+ classifies it; a late completion is discarded rather than folded.
+
+## Convergence and boundaries
+
+The cluster is converged for a generation when every desired slot holds the
+exact desired spec with `ready`, `Active` intent, and no pending operation,
+and no undesired or deleting nodes remain. `observed_generation ==
+generation` alone means only "the driver has evaluated that shape," not
+readiness.
+
+Deliberately out of scope (v1): persistence and crash recovery — the identity
+and adoption rules are the shape a later durability guarantee would build on —
+leader election, availability-budgeted rollouts, and any provider-specific
+behavior (backends live in application crates). The normative design spec,
+including the full invariants list, is archived at
+`docs/specs/archive/RECONCILER_SPEC.md`.
diff --git a/crates/provisioning/tests/common/mod.rs b/crates/provisioning/tests/common/mod.rs
new file mode 100644
index 0000000..4af2f8b
--- /dev/null
+++ b/crates/provisioning/tests/common/mod.rs
@@ -0,0 +1,1320 @@
+//! The provisioning test kit: a reusable conformance suite for the
+//! reconciler crate and any `ProvisionPlugin` implementation.
+//!
+//! Style: stateful property-based testing (an Erlang-QuickCheck-style
+//! command-sequence test without the framework). A trace is an explicit
+//! `Vec`; the harness folds it into a real `ClusterDriver` plus a
+//! real `IdempotentEffectExecutor` over a backend, settling the machine
+//! to quiescence after every input and checking guarantees. The oracle
+//! is the invariant checker only — there is no reference model.
+//! Failures panic with the seed and a shrunk minimal trace; paste that
+//! trace into a plain `#[test]` to pin a regression.
+//!
+//! Conformance levels (one battery, three backends):
+//! - `FakeBackend`: the reference in-memory substrate (fastest).
+//! - `PluginBackendAdapter` over an in-memory `TestablePlugin`.
+//! - `PluginBackendAdapter` over a real process-spawning plugin.
+//!
+//! Guarantee index (each item names its enforcement site):
+//! - identity: attempts unique, ordered, never reused (oracle)
+//! - correlation: pending ops belong to the current attempt (oracle)
+//! - dead nodes hold nothing: Destroyed => no lease/session/pending (oracle)
+//! - deleting nodes are not ready (oracle)
+//! - session coherence: active session => bootstrap facts (oracle)
+//! - readiness implies full facts and desired match (oracle)
+//! - attempt-fact ownership: lease/session facts never leak across
+//! attempts (oracle; fixture identities are attempt-encoded)
+//! - resource conservation: a converged machine leaks nothing
+//! (`HarnessedBackend::leaked`, checked at convergence)
+//! - quiescence: a forced pass on a quiescent machine is a no-op, a
+//! converged machine requeues nothing, and results are drained
+//! (harness, after every settle)
+//! - monotonic generation: observed_generation never regresses (harness)
+//! - replay determinism: identical traces yield identical state (test)
+//! - fair convergence: fault-free tails converge in bounded rounds and
+//! bounded replacement attempts (harness fair tail)
+//! - latest-desired-wins: a late shape change converges to it (test)
+//! - run-order confluence: FIFO vs LIFO work execution converge to the
+//! same state (test)
+//! - deadline boundary: operations expire exactly at their deadline (test)
+//! - clock extremes: saturated arithmetic never panics (test)
+//! - allocator exhaustion: reported as an error, not a spin (test)
+//!
+//! Note on backend calls for retired attempts: an effect dispatched
+//! before expiry may legitimately complete at the provider after its
+//! attempt is retired (real providers have latency). The executor's
+//! identity/adoption contract plus the driver's stale-result rejection
+//! make that safe; what must never happen — old-attempt facts surviving
+//! into a live attempt — is the attempt-fact-ownership oracle line.
+
+// Shared across test binaries; each binary uses a different subset.
+#![allow(dead_code)]
+
+use parking_lot::Mutex;
+use std::collections::{BTreeMap, BTreeSet, VecDeque};
+use std::convert::Infallible;
+use std::sync::Arc;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use provisioning::plugin::*;
+use provisioning::*;
+
+// ── fixtures ──────────────────────────────────────────────────────────
+
+pub fn group_with_role(id: &str, count: u32, role: &str) -> RunNodeGroupSpec {
+ RunNodeGroupSpec {
+ run_id: RunId(7),
+ group_id: NodeGroupId(id.to_owned()),
+ role: RoleId(role.to_owned()),
+ count,
+ provider: ProviderKind::new("mock"),
+ shape: DesiredNodeShape {
+ image: "node:v1".to_owned(),
+ disk_gb: 20,
+ gpu_name: None,
+ min_gpu_ram_mb: None,
+ min_down_mbps: None,
+ min_up_mbps: None,
+ min_reliability: None,
+ require_verified: false,
+ provider_labels: BTreeMap::new(),
+ },
+ boot: BootSpec {
+ ssh_user: "root".to_owned(),
+ verify_commands: vec!["true".to_owned()],
+ start_swactor_command: "swactor".to_owned(),
+ stdout_sources: Vec::new(),
+ stderr_sources: Vec::new(),
+ env: Vec::new(),
+ args: Vec::new(),
+ mounts: Vec::new(),
+ },
+ swarm_join: SwarmJoinTemplate {
+ orch_swactor_addr: "127.0.0.1:9000".to_owned(),
+ join_token_ref: "token".to_owned(),
+ },
+ }
+}
+
+pub fn group(id: &str, count: u32) -> RunNodeGroupSpec {
+ group_with_role(id, count, "worker")
+}
+
+pub fn shape(generation: u64, groups: Vec) -> ClusterShape {
+ ClusterShape {
+ run_id: RunId(7),
+ generation,
+ groups,
+ }
+}
+
+pub fn ssh_endpoint() -> SshEndpoint {
+ SshEndpoint {
+ host: "127.0.0.1".to_owned(),
+ port: 22,
+ user: "root".to_owned(),
+ auth_ref: "test-key".to_owned(),
+ }
+}
+
+/// Lease identity encodes the owning attempt, so fact leakage across
+/// attempts is detectable in pure state.
+pub fn lease_result(attempt: NodeAttemptId, endpoint: bool) -> CreateLeaseResult {
+ let provider = ProviderKind::new("mock");
+ let lease_id = ProviderLeaseId(format!("lease-{}", attempt.0));
+ CreateLeaseResult {
+ lease: LeaseFacts {
+ provider: provider.clone(),
+ lease_id: lease_id.clone(),
+ provider_contract_id: format!("contract-{}", attempt.0),
+ offer_id: None,
+ destroy_handle: DestroyHandle {
+ provider,
+ lease_id,
+ provider_contract_id: format!("contract-{}", attempt.0),
+ },
+ provider_metadata: BTreeMap::new(),
+ },
+ endpoint: endpoint.then(ssh_endpoint),
+ }
+}
+
+/// Bootstrap session identity encodes the owning attempt (sessions are
+/// `attempt * 1_000_000 + sequence`, sequences start at 1).
+pub const SESSION_SEQ_SPACE: u64 = 1_000_000;
+
+pub fn session_id_for(operation: OperationId) -> BootstrapSessionId {
+ assert!(
+ operation.sequence < SESSION_SEQ_SPACE,
+ "session id encoding exhausted"
+ );
+ BootstrapSessionId(operation.attempt.0 * SESSION_SEQ_SPACE + operation.sequence)
+}
+
+#[derive(Default)]
+pub struct RecordingExecutor {
+ pub submitted: usize,
+}
+
+impl EffectExecutor for RecordingExecutor {
+ type SubmitError = Infallible;
+
+ fn submit(&mut self, _effect: &PlannedEffect) -> Result<(), Self::SubmitError> {
+ self.submitted += 1;
+ Ok(())
+ }
+}
+
+pub struct NullSink;
+
+impl PluginObservationSink for NullSink {
+ fn observe(&self, _observation: PluginObservation) {}
+}
+
+pub fn null_sink() -> PluginSink {
+ PluginSink::new(Arc::new(NullSink))
+}
+
+/// A `NodeProvisionSpec` for a concrete attempt, for direct plugin calls.
+pub fn plugin_spec(attempt: u64) -> NodeProvisionSpec {
+ NodeProvisionSpec {
+ run_id: 7,
+ node_id: attempt,
+ attempt_id: attempt,
+ stage_index: None,
+ image: "kit-node".to_owned(),
+ env: Vec::new(),
+ args: Vec::new(),
+ mounts: Vec::new(),
+ }
+}
+
+// ── backend contract ──────────────────────────────────────────────────
+
+/// Scripted answer for the next backend call. An empty script means
+/// `Succeed`. `NoEndpoint` only affects lease creation (forces the
+/// endpoint-probe path); every other kind treats it as `Succeed`.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Reply {
+ Succeed,
+ NoEndpoint,
+ Definite(&'static str),
+ Ambiguous(&'static str),
+ Panic,
+}
+
+/// A backend the harness can drive: scripted faults, a healed (fair)
+/// mode, and a resource-conservation probe.
+pub trait HarnessedBackend: EffectBackend + Clone {
+ /// Script the next fault. `Reply::Succeed` means "no fault".
+ fn script(&mut self, reply: Reply);
+ /// Enter the fault-free mode (fair tail).
+ fn heal(&mut self);
+ /// Resources alive but not owned by any live lease (leaks).
+ fn leaked(&self) -> Vec;
+}
+
+// ── reference backend: fake substrate ─────────────────────────────────
+
+#[derive(Default)]
+struct BackendShared {
+ scripted: Mutex>,
+ calls: Mutex>,
+}
+
+#[derive(Clone, Default)]
+pub struct FakeBackend {
+ shared: Arc,
+}
+
+impl FakeBackend {
+ pub fn calls(&self) -> Vec {
+ self.shared.calls.lock().clone()
+ }
+}
+
+impl HarnessedBackend for FakeBackend {
+ fn script(&mut self, reply: Reply) {
+ self.shared.scripted.lock().push_back(reply);
+ }
+
+ fn heal(&mut self) {
+ self.shared.scripted.lock().clear();
+ }
+
+ fn leaked(&self) -> Vec {
+ Vec::new()
+ }
+}
+
+impl EffectBackend for FakeBackend {
+ fn execute(&self, effect: &PlannedEffect) -> Result {
+ self.shared.calls.lock().push(effect.clone());
+ let reply = self
+ .shared
+ .scripted
+ .lock()
+ .pop_front()
+ .unwrap_or(Reply::Succeed);
+ match reply {
+ Reply::Definite(reason) => return Err(EffectError::definite(reason)),
+ Reply::Ambiguous(reason) => return Err(EffectError::ambiguous(reason)),
+ Reply::Panic => panic!("scripted backend panic"),
+ Reply::Succeed | Reply::NoEndpoint => {}
+ }
+ let endpoint = !matches!(reply, Reply::NoEndpoint);
+ Ok(match &effect.command {
+ NodeManagerCommand::CreateLease(_) => {
+ OperationOutcome::LeaseCreated(lease_result(effect.operation.attempt, endpoint))
+ }
+ NodeManagerCommand::LookupEndpoint(_) => {
+ OperationOutcome::EndpointLookup(Some(ssh_endpoint()))
+ }
+ NodeManagerCommand::StartBootstrap(_) => OperationOutcome::BootstrapStarted {
+ session_id: session_id_for(effect.operation),
+ },
+ NodeManagerCommand::BootstrapConvergenceObserved { .. } => {
+ OperationOutcome::BootstrapConvergenceAccepted
+ }
+ NodeManagerCommand::CancelBootstrap { .. } => OperationOutcome::BootstrapCancelled,
+ NodeManagerCommand::DestroyLease(_) => OperationOutcome::LeaseDestroyed,
+ })
+ }
+}
+
+// ── plugin-level kit ──────────────────────────────────────────────────
+
+/// Fault a `TestablePlugin` can inject into its own behavior.
+/// The error string a `TestablePlugin` returns for `Fault::Ambiguous`.
+/// The `PluginBackendAdapter` reclassifies it as an ambiguous effect
+/// error; every other plugin error is definite.
+pub const AMBIGUOUS_FAULT_MARKER: &str = "kit-ambiguous: work may have happened";
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Fault {
+ /// Fail cleanly: the call errors and nothing is created.
+ Definite,
+ /// Fail ambiguously: the resource is created but the call errors.
+ /// A retry with the same attempt must adopt the resource, not
+ /// create a second one. The plugin reports this by returning
+ /// `AMBIGUOUS_FAULT_MARKER` from the call; the adapter reclassifies
+ /// that error as an ambiguous effect error so the driver records
+ /// `ambiguous_operation` and adopts on retry.
+ Ambiguous,
+ /// Panic inside the plugin call.
+ Panic,
+ /// Clear all injected faults.
+ Heal,
+}
+
+/// A `ProvisionPlugin` the kit can drive and probe. All probe methods
+/// use interior mutability so the plugin can live behind the adapter.
+pub trait TestablePlugin: ProvisionPlugin {
+ /// Queue the next fault (`Fault::Heal` clears all).
+ fn apply_fault(&self, fault: Fault);
+ /// Resources alive but not owned by `live_handles`.
+ fn leaked_resources(&self, live_handles: &[u64]) -> Vec;
+ /// Total resources ever created (adoption must not increment this).
+ fn resources_created(&self) -> usize;
+}
+
+struct LiveLease {
+ handle: PluginNodeHandle,
+ endpoint: Option,
+}
+
+struct AdapterShared
{
+ plugin: P,
+ live: BTreeMap,
+ withhold_endpoint: bool,
+}
+
+fn classify_plugin_error(error: String) -> EffectError {
+ if error == AMBIGUOUS_FAULT_MARKER {
+ EffectError::ambiguous(error)
+ } else {
+ EffectError::definite(error)
+ }
+}
+
+/// The kit's `EffectBackend` over a `ProvisionPlugin`: maps
+/// `NodeManagerCommand`s to plugin calls, records live leases per
+/// attempt, and synthesizes lease/session identities using the oracle's
+/// attempt-encoded conventions. Create adopts: an existing live lease
+/// for the same attempt is returned instead of calling the plugin
+/// again (mirrors provider-side idempotency keys).
+pub struct PluginBackendAdapter