diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a5f350..6072716 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,22 @@ cut and that this clone does not carry.
## [Unreleased]
+## [0.0.68] - 2026-09-07
+
+Complete native setup preservation captures user additions, installed plugin
+state and measured configuration outside portable installation ownership.
+Returning to a saved setup first preserves current edits, then restores exact
+covered bytes, empty directories and supported permissions. Complete backup
+format 2 prevents legacy readers from misinterpreting the coverage base.
+
+The OpenCode provider retains prior setup identity and written ownership.
+Prepared recovery restores previous provider metadata even after an interrupted
+state write. Complete snapshots remain held against rolling retention and status
+reports verified recovery integrity and current native-state comparison.
+
+This release uses consumer kit 0.2.11. Install a compatible released ai-stp CLI
+reader before using the new provider declaration.
+
## [0.0.67] - 2026-09-07
nddev-builder creates complete native tool collections: select and author
diff --git a/Cargo.lock b/Cargo.lock
index 8ffc326..b299e06 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "harness-runtime"
-version = "0.0.67"
+version = "0.0.68"
dependencies = [
"provider-v3",
"serde",
@@ -128,7 +128,7 @@ dependencies = [
[[package]]
name = "opencode-setup-system"
-version = "0.0.67"
+version = "0.0.68"
dependencies = [
"harness-runtime",
"provider-v3",
@@ -147,7 +147,7 @@ dependencies = [
[[package]]
name = "provider-v3"
-version = "0.0.67"
+version = "0.0.68"
dependencies = [
"serde",
"serde_json",
@@ -209,7 +209,7 @@ dependencies = [
[[package]]
name = "setup-core"
-version = "0.0.67"
+version = "0.0.68"
dependencies = [
"miniz_oxide",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index 18c9f0f..5d7f456 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
-version = "0.0.67"
+version = "0.0.68"
edition = "2024"
rust-version = "1.89"
license = "AGPL-3.0-or-later"
@@ -23,9 +23,9 @@ sha2 = "0.11"
# `setup-core::archive`); an inflate loop is not, because its bugs are
# memory-safety bugs and it is not improved by being hand-written here.
miniz_oxide = "0.9"
-setup-core = { path = "crates/setup-core", version = "0.0.67" }
-provider-v3 = { path = "crates/provider-v3", version = "0.0.67" }
-harness-runtime = { path = "crates/harness-runtime", version = "0.0.67" }
+setup-core = { path = "crates/setup-core", version = "0.0.68" }
+provider-v3 = { path = "crates/provider-v3", version = "0.0.68" }
+harness-runtime = { path = "crates/harness-runtime", version = "0.0.68" }
[workspace.lints.rust]
unsafe_code = "forbid"
diff --git a/README.md b/README.md
index c7c20f4..03cd142 100644
--- a/README.md
+++ b/README.md
@@ -179,7 +179,7 @@ release is a convenience, not the authorised copy.
```bash
docker run --rm -v "$HOME/.config:/config" \
- ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.67 \
+ ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.68 \
status --target /config/
--json
```
diff --git a/crates/harness-runtime/src/facts.rs b/crates/harness-runtime/src/facts.rs
index 0d39559..632cf60 100644
--- a/crates/harness-runtime/src/facts.rs
+++ b/crates/harness-runtime/src/facts.rs
@@ -174,6 +174,10 @@ pub struct Harness {
/// Excluded from backups so a slot never holds credentials, and excluded
/// from target identity so the product's own traffic cannot strand a plan.
pub never_touch: &'static [&'static str],
+ /// Complete preservation surfaces that differ from installation ownership.
+ /// These may preserve product-managed plugin bytes without making them
+ /// writable destinations for portable component installation.
+ pub preservation_surfaces: &'static [PreservationSurface],
/// What a *neighbour's* configuration home looks like from inside a target.
///
/// Every command here takes an explicit `--target` because a change aimed at
@@ -336,6 +340,17 @@ pub const BACKUP_SLOTS: usize = 10;
/// The bundle format every setup system reads.
pub const BUNDLE_FORMAT: &str = "ai-stp-bundle/2";
+/// A native configuration cover used only for explicit complete preservation.
+#[derive(Debug, Clone, Copy)]
+pub struct PreservationSurface {
+ /// The target scope this surface describes.
+ pub scope: Option,
+ /// All covered target-relative configuration roots.
+ pub roots: &'static [&'static str],
+ /// Credential and runtime paths never copied or restored.
+ pub excluded: &'static [&'static str],
+}
+
impl Harness {
/// Whether one relative path falls inside a namespace this harness claims.
///
@@ -524,6 +539,28 @@ impl Harness {
names
}
+ /// Complete native coverage is independent of portable installation routes.
+ #[must_use]
+ pub fn preservation_surface(
+ &self,
+ scope: Option,
+ ) -> (Vec<&'static str>, Vec<&'static str>) {
+ let mut roots = self.owned_projection(scope).to_vec();
+ let mut excluded = self.never_captured();
+ if let Some(surface) = self
+ .preservation_surfaces
+ .iter()
+ .find(|surface| surface.scope == scope)
+ {
+ roots.extend_from_slice(surface.roots);
+ excluded = vec![self.control_directory];
+ excluded.extend_from_slice(surface.excluded);
+ }
+ roots.sort_unstable();
+ roots.dedup();
+ (roots, excluded)
+ }
+
/// A digest of this build's own manifest.
///
/// The contract is explicit that the release digest must not come from
@@ -921,6 +958,7 @@ mod tests {
native_namespaces: &["AGENTS.md", "settings.json", "skills"],
shadowing_names: &[],
custody_namespaces: &[],
+ preservation_surfaces: &[],
never_touch: &[".credentials.json", "sessions"],
foreign_homes: &[],
permission_profiles: &["default"],
diff --git a/crates/harness-runtime/src/human.rs b/crates/harness-runtime/src/human.rs
index f878c82..a5b9e56 100644
--- a/crates/harness-runtime/src/human.rs
+++ b/crates/harness-runtime/src/human.rs
@@ -1105,6 +1105,20 @@ fn mutate(
(effect, None)
};
+ let control = resolved.ensure_control_directory()?;
+ let pool = Pool::open(&control, facts::BACKUP_SLOTS)?;
+ let native_capture = wire::plan_native_capture(
+ harness,
+ &resolved,
+ HUMAN_SCOPE,
+ operation,
+ None,
+ match &effect {
+ Effect::Restore { backup_ref } => backup_ref.as_deref(),
+ _ => None,
+ },
+ &pool,
+ )?;
let artifact = PlanArtifact::new(PlanInputs {
// No scope: the human surface is a person at a terminal, and a
// scope is something a consumer resolves. Omitted rather than
@@ -1128,6 +1142,7 @@ fn mutate(
_ => None,
},
restore_target_digest,
+ native_capture,
permission_profile: None,
expires_at: &expiry::deadline_in(PLAN_WINDOW_SECONDS, SystemTime::now()),
// The human surface drives configuration, never the product's own
diff --git a/crates/harness-runtime/src/lib.rs b/crates/harness-runtime/src/lib.rs
index b90d4f2..9e27700 100644
--- a/crates/harness-runtime/src/lib.rs
+++ b/crates/harness-runtime/src/lib.rs
@@ -46,7 +46,10 @@ pub use catalog::{Catalog, Setup};
// The software types belong to the kernel, but a setup system declares its
// artifact table and depends only on this crate. Re-exported so that stays
// true rather than widening seven dependency lists to reach past it.
-pub use facts::{BACKUP_SLOTS, BUNDLE_FORMAT, Foreign, Harness, LaunchBinding, Scoped, Shadow};
+pub use facts::{
+ BACKUP_SLOTS, BUNDLE_FORMAT, Foreign, Harness, LaunchBinding, PreservationSurface, Scoped,
+ Shadow,
+};
pub use setup_core::software::{Artifact, Delivery, Previous, Shape, Software};
/// The kernel's content digest, re-exported for the seven binaries.
diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs
index 8e755aa..92399d4 100644
--- a/crates/harness-runtime/src/wire.rs
+++ b/crates/harness-runtime/src/wire.rs
@@ -23,10 +23,11 @@ use std::time::SystemTime;
use provider_v3::argv::{Bundle as ArgvBundle, Invocation, PlanRequest};
use provider_v3::bundle::{Bundle, Claim, FILES_PREFIX};
-use provider_v3::plan::{EndState, PlanArtifact, PlanInputs};
+use provider_v3::plan::{EndState, NativeCapture, PlanArtifact, PlanInputs};
use provider_v3::{Error, Operation, Result, WireReason};
use setup_core::backup::{BackupRef, Pool, SLOT_SCHEMA, SlotRecord};
use setup_core::journal::{JOURNAL_SCHEMA, Journal, Phase};
+use setup_core::native_snapshot::{NativeBase, NativeSnapshot};
use setup_core::stamp::{DriftState, ProviderState, STATE_SCHEMA, StateReading};
use setup_core::target::Target;
use setup_core::{digest, lock};
@@ -327,7 +328,66 @@ fn status(
let owned = owned_here(harness, &resolved, scope)?;
let identity = resolved.identity_of_owned(&as_paths(&owned), &harness.not_our_identity())?;
let journal = Journal::read(&control).ok().flatten();
- status_of(harness, &resolved, &pool, &identity, journal)
+ status_of(harness, &resolved, &pool, &identity, journal, scope)
+}
+
+fn backup_status(
+ pool: &Pool,
+ resolved: &Target,
+ harness: &Harness,
+ scope: Option,
+) -> Result {
+ let held = pool.held()?;
+ let records = pool.list()?;
+ // One current-state observation serves every retained snapshot comparison.
+ let current = if records
+ .iter()
+ .any(|record| record.native_snapshot.is_some())
+ {
+ inspect_native_surface(harness, resolved, scope)
+ .ok()
+ .map(|(_, snapshot)| snapshot)
+ } else {
+ None
+ };
+ let mut entries = Vec::new();
+ for record in records {
+ let holder = held
+ .iter()
+ .find(|(reference, _)| *reference == record.backup_ref);
+ let mut entry = serde_json::json!({
+ "backup_ref": record.backup_ref.as_str(),
+ "operation": record.operation,
+ "setup_id": record.setup_id,
+ "held": holder.is_some(),
+ "hold_reason": holder.map(|(_, reason)| reason.clone()),
+ });
+ if let Some(snapshot) = &record.native_snapshot {
+ let verification = if pool.payload_of(&record.backup_ref).is_ok() {
+ "verified"
+ } else {
+ "unavailable"
+ };
+ let target_state = current.as_ref().map_or("unavailable", |current| {
+ if current == snapshot {
+ "matches"
+ } else {
+ "differs"
+ }
+ });
+ entry["native_snapshot"] = serde_json::json!({
+ "digest": snapshot.digest()?,
+ "base_root": snapshot.base_root,
+ "operation_id": record.operation_id,
+ "roots": snapshot.roots,
+ "excluded": snapshot.excluded,
+ "verification": verification,
+ "target_state": target_state,
+ });
+ }
+ entries.push(entry);
+ }
+ Ok(entries.into())
}
/// Which scope `status` measures a target under.
@@ -369,6 +429,7 @@ fn status_of(
pool: &Pool,
identity: &str,
journal: Option,
+ scope: Option,
) -> Result {
let reading = ProviderState::read(resolved.root(), harness.state_file)?;
// `managed` carries our state; `unmanaged` holds content that is not ours;
@@ -475,38 +536,7 @@ fn status_of(
None => serde_json::Value::Null,
},
),
- ("backups", {
- // A hold is the difference between a reference a plan can rely
- // on and one retention may take out from under it. The pool has
- // known which slots are held since 0.0.6; `status` did not say,
- // so a consumer could only find out by watching a baseline
- // disappear after fifty captures -- which is the failure the
- // hold exists to prevent, discovered the same way.
- //
- // Read here rather than in the map below because `held` walks
- // the pool once; asking per slot would be one walk per slot.
- let held = pool.held()?;
- pool.list()?
- .iter()
- .map(|record| {
- let holder = held
- .iter()
- .find(|(reference, _)| *reference == record.backup_ref);
- serde_json::json!({
- "backup_ref": record.backup_ref.as_str(),
- "operation": record.operation,
- "setup_id": record.setup_id,
- "held": holder.is_some(),
- // The reason, not only the fact. A caller deciding
- // whether it may release one needs to know whose
- // baseline it would be taking, which is exactly what
- // the refusal on `hold` already says.
- "hold_reason": holder.map(|(_, reason)| reason.clone()),
- })
- })
- .collect::>()
- .into()
- }),
+ ("backups", backup_status(pool, resolved, harness, scope)?),
] {
answer.insert(key.to_owned(), value);
}
@@ -768,13 +798,26 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result Result Result Result Result<()> {
fn restore_target_identity(
harness: &Harness,
payload: &Path,
- _scope: Option,
+ record: &SlotRecord,
+ target: &Target,
) -> Result {
- let owned = files_in_payload(payload)?;
+ let complete = record.native_snapshot.as_ref();
+ let owned = if complete.is_some() {
+ record.previous_written_paths.clone().unwrap_or_default()
+ } else {
+ files_in_payload(payload)?
+ };
+ let identity_root = if complete.is_some_and(|snapshot| snapshot.base_root == NativeBase::Parent)
+ {
+ payload.join(target.root().file_name().ok_or_else(|| {
+ Error::refuse(
+ WireReason::UnsupportedNativeSurface,
+ "the native target has no leaf directory",
+ )
+ })?)
+ } else {
+ payload.to_path_buf()
+ };
Ok(setup_core::digest::of_owned(
- payload,
+ &identity_root,
&as_paths(&owned),
&harness.not_our_identity(),
)?)
}
+/// A closed cover; the parent is used only for Claude's documented global companion.
+fn inspect_native_surface(
+ harness: &Harness,
+ target: &Target,
+ scope: Option,
+) -> Result<(std::path::PathBuf, NativeSnapshot)> {
+ let (roots, excluded) = harness.preservation_surface(scope);
+ if harness.harness_id == "claude-code"
+ && scope.is_none()
+ && target
+ .root()
+ .file_name()
+ .is_some_and(|name| name == ".claude")
+ {
+ let root = target.root().parent().ok_or_else(|| {
+ Error::refuse(
+ WireReason::UnsupportedNativeSurface,
+ "the Claude target has no companion directory",
+ )
+ })?;
+ let mut roots: Vec = roots.iter().map(|path| format!(".claude/{path}")).collect();
+ roots.push(".claude.json".to_owned());
+ let excluded: Vec = excluded
+ .iter()
+ .map(|path| format!(".claude/{path}"))
+ .collect();
+ let mut snapshot = NativeSnapshot::inspect(root, &as_paths(&roots), &as_paths(&excluded))?;
+ snapshot.base_root = NativeBase::Parent;
+ Ok((root.to_path_buf(), snapshot))
+ } else {
+ Ok((
+ target.root().to_path_buf(),
+ NativeSnapshot::inspect(target.root(), &roots, &excluded)?,
+ ))
+ }
+}
+
+/// Bind explicit complete preservation or restoration of a complete snapshot.
+pub(crate) fn plan_native_capture(
+ harness: &Harness,
+ target: &Target,
+ scope: Option,
+ operation: Operation,
+ capture_mode: Option<&str>,
+ backup_ref: Option<&str>,
+ pool: &Pool,
+) -> Result