diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73daf34..42b8adc 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 Cursor CLI 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 ec6a616..33b9093 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -49,7 +49,7 @@ dependencies = [
[[package]]
name = "cursor-setup-system"
-version = "0.0.67"
+version = "0.0.68"
dependencies = [
"harness-runtime",
"provider-v3",
@@ -76,7 +76,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "harness-runtime"
-version = "0.0.67"
+version = "0.0.68"
dependencies = [
"provider-v3",
"serde",
@@ -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 70b36c2..fb2f0e2 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 1f40d78..7c925e1 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/cursor-setup-system:0.0.67 \
+ ghcr.io/nddev-opennetwork/cursor-setup-system:0.0.68 \
status --target /config/
--json
```
diff --git a/crates/cursor-setup-system/src/main.rs b/crates/cursor-setup-system/src/main.rs
index 2430a5e..ffe5e5c 100644
--- a/crates/cursor-setup-system/src/main.rs
+++ b/crates/cursor-setup-system/src/main.rs
@@ -21,7 +21,7 @@ use std::process::ExitCode;
mod software;
-use harness_runtime::{Harness, LaunchBinding, Scoped};
+use harness_runtime::{Harness, LaunchBinding, PreservationSurface, Scoped};
use provider_v3::{ComponentKind, ProjectionKind, TargetScope};
/// Everything specific to Cursor CLI, verified against `cursor-baseline.json`.
@@ -173,6 +173,11 @@ pub const CURSOR: Harness = Harness {
// Every owned namespace here routes a kind or is filled by a setup,
// so exact state has something to say about each one.
custody_namespaces: &[],
+ preservation_surfaces: &[PreservationSurface {
+ scope: None,
+ roots: &["plugins", "sandbox.json"],
+ excluded: &["auth.json", "sessions"],
+ }],
never_touch: &["auth.json", "sessions"],
// No near neighbour measured for this product. A marker listed here is a
// refusal waiting to happen, so nothing is listed without evidence.
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> {
+ if capture_mode.is_some()
+ && (capture_mode != Some("complete_native")
+ || !matches!(
+ operation,
+ Operation::Backup
+ | Operation::Install
+ | Operation::Replace
+ | Operation::Remove
+ | Operation::Reset
+ ))
+ {
+ return Err(Error::refuse(
+ WireReason::UnsupportedOperation,
+ "capture-mode complete_native is supported only for native configuration mutations",
+ ));
+ }
+ let restore = if operation == Operation::Restore {
+ let record = chosen_backup(pool, backup_ref)?;
+ pool.payload_of(&record.backup_ref)?;
+ record.native_snapshot
+ } else {
+ None
+ };
+ if capture_mode.is_none() && restore.is_none() {
+ return Ok(None);
+ }
+ let (_, current) = inspect_native_surface(harness, target, scope)?;
+ if let Some(saved) = &restore
+ && (saved.base_root != current.base_root
+ || saved.roots != current.roots
+ || saved.excluded != current.excluded)
+ {
+ return Err(Error::refuse(
+ WireReason::UnsupportedNativeSurface,
+ "the saved native surface differs from this provider's declared coverage",
+ ));
+ }
+ Ok(Some(NativeCapture {
+ base_root: current.base_root,
+ current_digest: current.digest()?,
+ restore_digest: restore.map(|snapshot| snapshot.digest()).transpose()?,
+ roots: current.roots,
+ excluded: current.excluded,
+ }))
+}
+
/// Every regular payload file, relative to the payload root.
fn files_in_payload(payload: &Path) -> Result> {
let mut found = Vec::new();
@@ -1615,19 +1777,52 @@ pub(crate) fn perform(
// Re-check after the lock: everything observed before it could have moved.
let owned = owned_here(harness, &resolved, mutation.target_scope)?;
- let identity_paths = snapshot_if_unmanaged_backup(
+ let identity_paths = if mutation.provenance.get("native_capture").is_some() {
+ owned.clone()
+ } else {
+ snapshot_if_unmanaged_backup(
+ harness,
+ &resolved,
+ mutation.target_scope,
+ &owned,
+ mutation.operation,
+ )?
+ };
+ let identity =
+ resolved.identity_of_owned(&as_paths(&identity_paths), &harness.not_our_identity())?;
+ if identity != mutation.expected_target_digest {
+ return Err(Error::refuse(
+ WireReason::Stale,
+ "the target changed after the lock was taken; no effect was made",
+ ));
+ }
+ let native_capture: Option = mutation
+ .provenance
+ .get("native_capture")
+ .map(|value| serde_json::from_value(value.clone()))
+ .transpose()
+ .map_err(|_| Error::refuse(WireReason::Stale, "invalid native capture binding"))?;
+ let restoring = match &mutation.effect {
+ Effect::Restore { backup_ref } => backup_ref.as_deref(),
+ _ => None,
+ };
+ let native_expected = plan_native_capture(
harness,
&resolved,
mutation.target_scope,
- &owned,
mutation.operation,
+ if native_capture.is_some() && mutation.operation != Operation::Restore {
+ Some("complete_native")
+ } else {
+ None
+ },
+ restoring,
+ &pool,
)?;
- let identity =
- resolved.identity_of_owned(&as_paths(&identity_paths), &harness.not_our_identity())?;
- if identity != mutation.expected_target_digest {
+ if native_capture != native_expected {
return Err(Error::refuse(
WireReason::Stale,
- "the target changed after the lock was taken; no effect was made",
+ "complete native state changed after planning; no effect was made",
));
}
setup_core::journal::require_clean_for_planning(
@@ -1655,17 +1850,49 @@ pub(crate) fn perform(
)?;
refuse_uncapturable(&resolved, &capture)?;
refuse_an_unrecorded_removal(harness, &resolved, mutation)?;
- let captured = pool.capture(resolved.root(), &as_paths(&capture), |backup_ref| {
- SlotRecord {
- schema_version: SLOT_SCHEMA,
- backup_ref,
- operation: operation_name.clone(),
- operation_id: operation_id.clone(),
- target_identity_digest: identity.clone(),
- setup_id: previous_setup.clone(),
- setup_definition_digest: previous_definition.clone(),
+ let previous_provider_state = match ProviderState::read(resolved.root(), harness.state_file)? {
+ StateReading::Current(state) => Some(state),
+ _ => None,
+ };
+ let record_capture = |backup_ref| SlotRecord {
+ schema_version: SLOT_SCHEMA,
+ backup_ref,
+ operation: operation_name.clone(),
+ operation_id: operation_id.clone(),
+ target_identity_digest: identity.clone(),
+ setup_id: previous_setup.clone(),
+ setup_definition_digest: previous_definition.clone(),
+ native_snapshot: None,
+ previous_written_paths: Some(previous_written.clone()),
+ previous_provider_state: previous_provider_state.clone(),
+ };
+ let selected_hold = if native_capture.is_some() {
+ restoring
+ .map(|reference| {
+ let reference = BackupRef::parse(reference)?;
+ let added = pool.hold(
+ &reference,
+ &format!("restore operation {}", mutation.operation_id),
+ )?;
+ Ok::<_, Error>((reference, added))
+ })
+ .transpose()?
+ } else {
+ None
+ };
+ let captured = if let Some(binding) = &native_capture {
+ let (source_root, snapshot) =
+ inspect_native_surface(harness, &resolved, mutation.target_scope)?;
+ if snapshot.digest()? != binding.current_digest {
+ return Err(Error::refuse(
+ WireReason::Stale,
+ "native state changed before capture; no target effect was made",
+ ));
}
- })?;
+ pool.capture_native(&source_root, &snapshot, record_capture)?
+ } else {
+ pool.capture(resolved.root(), &as_paths(&capture), record_capture)?
+ };
let journal = Journal {
schema_version: JOURNAL_SCHEMA,
@@ -1692,7 +1919,12 @@ pub(crate) fn perform(
// means "the files this provider has written at this target", not "the
// files this operation wrote", and an operation that writes none leaves
// it as it found it.
- Effect::Backup => Ok(previous_written.clone()),
+ Effect::Backup => {
+ if let Some(state) = &previous_provider_state {
+ copy_applied_identity(&mut applied, state);
+ }
+ Ok(previous_written.clone())
+ }
Effect::Restore { backup_ref } => {
let record = chosen_backup(&pool, backup_ref.as_deref())?;
let payload = pool.payload_of(&record.backup_ref)?;
@@ -1703,7 +1935,17 @@ pub(crate) fn perform(
applied
.setup_definition_digest
.clone_from(&record.setup_definition_digest);
- replace_managed_from(harness, &resolved, &payload, mutation.target_scope, false)
+ if let Some(state) = &record.previous_provider_state {
+ copy_applied_identity(&mut applied, state);
+ }
+ if let Some(snapshot) = &record.native_snapshot {
+ let (source_root, _) =
+ inspect_native_surface(harness, &resolved, mutation.target_scope)?;
+ snapshot.restore(&payload, &source_root)?;
+ Ok(record.previous_written_paths.clone().unwrap_or_default())
+ } else {
+ replace_managed_from(harness, &resolved, &payload, mutation.target_scope, false)
+ }
}
// Removal puts nothing on the target and leaves nothing of ours there,
// so an empty list is the true answer rather than a missing one --
@@ -1755,6 +1997,9 @@ pub(crate) fn perform(
)?;
journal.promote_to_committed(&control)?;
Journal::clear(&control)?;
+ if let Some((reference, true)) = selected_hold {
+ pool.release(&reference)?;
+ }
Ok(serde_json::json!({
"state": "verified",
@@ -1882,10 +2127,12 @@ fn recover(harness: &Harness, target: &Path) -> Result {
let _guard = setup_core::lock::TargetLock::acquire(&control)?;
let Some(journal) = Journal::read(&control)? else {
+ let quarantined = pool.quarantine_partial()?;
return Ok(serde_json::json!({
"state": "verified",
- "recovered": false,
- "detail": "no journal is published; there is nothing to resolve",
+ "recovered": quarantined != 0,
+ "detail": if quarantined == 0 { "no journal is published; there is nothing to resolve" }
+ else { "incomplete captures were retained in the backup archive; the target was not changed" },
}));
};
@@ -1910,13 +2157,53 @@ fn recover(harness: &Harness, target: &Path) -> Result {
};
let backup_ref = BackupRef::parse(reference)?;
let payload = pool.payload_of(&backup_ref)?;
- replace_managed_from(harness, &resolved, &payload, scope, false)?;
+ let record = chosen_backup(&pool, Some(reference))?;
+ if let Some(snapshot) = &record.native_snapshot {
+ let (source_root, current) = inspect_native_surface(harness, &resolved, scope)?;
+ if current.base_root != snapshot.base_root
+ || current.roots != snapshot.roots
+ || current.excluded != snapshot.excluded
+ {
+ return Err(Error::refuse(
+ WireReason::RecoveryRequired,
+ "native recovery surface differs from this provider",
+ ));
+ }
+ if let Some(state) = &record.previous_provider_state
+ && (state.canonical_target != resolved.root().to_string_lossy()
+ || state.provider_id != harness.provider_id
+ || state.harness_id != harness.harness_id)
+ {
+ return Err(Error::refuse(
+ WireReason::RecoveryRequired,
+ "saved provider metadata belongs to another target",
+ ));
+ }
+ snapshot.restore(&payload, &source_root)?;
+ if let Some(state) = &record.previous_provider_state {
+ state.write(resolved.root(), harness.state_file)?;
+ } else if record.previous_written_paths.is_some() {
+ let state_path = resolved.root().join(harness.state_file);
+ if state_path.exists() {
+ fs::remove_file(&state_path).map_err(|error| {
+ Error::refuse(
+ WireReason::RecoveryRequired,
+ format!("cannot restore absent provider metadata: {error}"),
+ )
+ })?;
+ }
+ }
+ } else {
+ replace_managed_from(harness, &resolved, &payload, scope, false)?;
+ }
+ let owned = owned_here(harness, &resolved, scope)?;
Journal::clear(&control)?;
Ok(serde_json::json!({
"state": "verified",
"recovered": true,
"phase": Phase::Prepared.as_str(),
"restored_from": reference,
+ "target_digest": resolved.identity_of_owned(&as_paths(&owned), &harness.not_our_identity())?,
"target_identity_digest": resolved.identity_of_owned(&as_paths(&owned), &harness.not_our_identity())?,
}))
}
@@ -1927,6 +2214,7 @@ fn recover(harness: &Harness, target: &Path) -> Result {
"state": "verified",
"recovered": true,
"phase": Phase::Committed.as_str(),
+ "target_digest": resolved.identity_of_owned(&as_paths(&owned), &harness.not_our_identity())?,
"target_identity_digest": resolved.identity_of_owned(&as_paths(&owned), &harness.not_our_identity())?,
}))
}
@@ -2719,6 +3007,22 @@ fn refuse_uncapturable(resolved: &Target, owned: &[String]) -> Result<()> {
))
}
+/// Preserve setup identity independently of the new operation provenance.
+fn copy_applied_identity(applied: &mut Applied, state: &ProviderState) {
+ applied.setup_id.clone_from(&state.setup_stable_id);
+ applied.setup_version.clone_from(&state.setup_version);
+ applied
+ .setup_version_passport_digest
+ .clone_from(&state.setup_version_passport_digest);
+ applied
+ .setup_definition_digest
+ .clone_from(&state.setup_definition_digest);
+ applied.component_refs.clone_from(&state.component_refs);
+ applied.bundle_format.clone_from(&state.bundle_format);
+ applied.bundle_digest.clone_from(&state.bundle_digest);
+ applied.artifact_digest.clone_from(&state.artifact_digest);
+}
+
/// Record what this operation leaves behind, as the contract asks it to.
///
/// Takes the whole [`Mutation`] rather than the two fields it needs from it.
@@ -2982,6 +3286,7 @@ pub(crate) mod tests_support {
native_namespaces: &["AGENTS.md", "settings.json", "skills"],
shadowing_names: &[],
custody_namespaces: &[],
+ preservation_surfaces: &[],
never_touch: &[".credentials.json", "sessions"],
foreign_homes: &[],
permission_profiles: &["default"],
@@ -5030,6 +5335,83 @@ mod tests {
assert_eq!(status["provider_state"]["drift_state"], "clean");
}
+ #[test]
+ fn complete_native_return_preserves_user_additions_and_restores_empty_directories() {
+ let target = seeded("complete-return");
+ // A prior provider operation must not narrow a later complete capture.
+ plan_then_apply(&target, "backup", &[]);
+ fs::create_dir_all(target.join("skills/empty")).unwrap();
+ fs::write(target.join("skills/user.py"), b"print('user')\n").unwrap();
+ let baseline =
+ NativeSnapshot::inspect(&target, TEST.native_namespaces, &TEST.never_captured())
+ .unwrap();
+ let saved = plan_then_apply(&target, "backup", &["--capture-mode", "complete_native"]);
+ let reference = saved["backup_ref"].as_str().unwrap();
+ fs::write(target.join("skills/new.sh"), b"echo new\n").unwrap();
+ fs::write(target.join("AGENTS.md"), b"new instructions").unwrap();
+ fs::remove_dir(target.join("skills/empty")).unwrap();
+ let edited =
+ NativeSnapshot::inspect(&target, TEST.native_namespaces, &TEST.never_captured())
+ .unwrap();
+ let restored = plan_then_apply(&target, "restore", &["--backup-ref", reference]);
+ assert_eq!(restored["state"], "verified");
+ baseline.verify(&target).unwrap();
+ assert!(!target.join("skills/new.sh").exists());
+ assert_eq!(fs::read(target.join("unrelated.txt")).unwrap(), b"keep me");
+ let edited_ref = restored["backup_ref"].as_str().unwrap();
+ plan_then_apply(&target, "restore", &["--backup-ref", edited_ref]);
+ edited.verify(&target).unwrap();
+ }
+
+ #[test]
+ fn complete_native_capture_rejects_changes_outside_the_written_inventory() {
+ let target = seeded("complete-stale");
+ plan_then_apply(&target, "backup", &[]);
+ let planned = run(args(
+ "plan-operation",
+ &target,
+ &[
+ "--operation",
+ "backup",
+ "--capture-mode",
+ "complete_native",
+ "--provider-release-digest",
+ RELEASE,
+ "--operation-id",
+ "operation_01NATIVE",
+ "--expires-at",
+ far_future(),
+ ],
+ ));
+ let path = target.parent().unwrap().join("native-plan.json");
+ fs::write(
+ &path,
+ setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(),
+ )
+ .unwrap();
+ fs::write(target.join("skills/new.py"), b"user addition").unwrap();
+ let error = refuse(args(
+ "apply-operation",
+ &target,
+ &[
+ "--plan",
+ &path.to_string_lossy(),
+ "--plan-digest",
+ planned["plan_digest"].as_str().unwrap(),
+ "--provider-release-digest",
+ RELEASE,
+ ],
+ ));
+ assert_eq!(error.reason(), Some(WireReason::Stale));
+ assert_eq!(
+ run(args("status", &target, &[]))["backups"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ }
+
#[test]
fn a_backup_never_copies_product_owned_credentials() {
let target = seeded("no-secrets");
@@ -5368,6 +5750,67 @@ mod tests {
assert_eq!(planned["state"], "planned");
}
+ #[test]
+ fn complete_native_recovery_rewinds_a_state_written_before_journal_commit() {
+ for managed in [false, true] {
+ let target = seeded(if managed {
+ "recover-native-managed"
+ } else {
+ "recover-native-unmanaged"
+ });
+ if managed {
+ plan_then_apply(&target, "backup", &[]);
+ let StateReading::Current(mut state) =
+ ProviderState::read(&target, TEST.state_file).unwrap()
+ else {
+ panic!("state missing");
+ };
+ state.setup_version = Some("1.7".to_owned());
+ state.component_refs = vec!["component_original@1.0".to_owned()];
+ state.write(&target, TEST.state_file).unwrap();
+ }
+ let state_path = target.join(TEST.state_file);
+ let original_state = fs::read(&state_path).ok();
+ let original = fs::read(target.join("AGENTS.md")).unwrap();
+ let saved = plan_then_apply(&target, "backup", &["--capture-mode", "complete_native"]);
+ let StateReading::Current(mut state) =
+ ProviderState::read(&target, TEST.state_file).unwrap()
+ else {
+ panic!("state missing");
+ };
+ if managed {
+ assert_eq!(state.setup_version.as_deref(), Some("1.7"));
+ assert_eq!(state.component_refs, vec!["component_original@1.0"]);
+ }
+ state.setup_version = Some("9.9".to_owned());
+ state.written_paths = vec!["skills/unrecorded".to_owned()];
+ state.write(&target, TEST.state_file).unwrap();
+ fs::write(target.join("AGENTS.md"), b"half-written").unwrap();
+ fs::write(target.join("skills/unrecorded"), b"partial new file").unwrap();
+ Journal {
+ schema_version: JOURNAL_SCHEMA,
+ phase: Phase::Prepared,
+ operation_id: state.operation_id,
+ operation: "backup".to_owned(),
+ plan_digest: saved["plan_digest"].as_str().unwrap().to_owned(),
+ target_precondition_digest: saved["expected_target_digest"]
+ .as_str()
+ .unwrap()
+ .to_owned(),
+ backup_ref: Some(saved["backup_ref"].as_str().unwrap().to_owned()),
+ target_scope: None,
+ }
+ .publish_prepared(&target.join(TEST.control_directory))
+ .unwrap();
+ let recovered = run(args("recover-operation", &target, &[]));
+ assert_eq!(recovered["state"], "verified");
+ assert_eq!(recovered["target_digest"], saved["expected_target_digest"]);
+ assert_eq!(fs::read(&state_path).ok(), original_state);
+ assert_eq!(fs::read(target.join("AGENTS.md")).unwrap(), original);
+ assert!(!target.join("skills/unrecorded").exists());
+ }
+ }
+
#[test]
fn recovery_with_no_journal_says_so_rather_than_inventing_work() {
let target = seeded("recover-clean");
@@ -5377,6 +5820,31 @@ mod tests {
);
}
+ #[test]
+ fn recovery_retains_unpublished_captures_and_unblocks_future_plans() {
+ let target = seeded("recover-unpublished");
+ let (_, control, pool) = open(&TEST, &target).unwrap();
+ let partial = control
+ .join(setup_core::backup::POOL_DIRECTORY_NAME)
+ .join("slot-000000000001");
+ fs::create_dir_all(partial.join("payload")).unwrap();
+ fs::write(partial.join("payload/AGENTS.md"), b"interrupted copy").unwrap();
+ let original = fs::read(target.join("AGENTS.md")).unwrap();
+ assert_eq!(
+ run(args("recover-operation", &target, &[]))["recovered"],
+ true
+ );
+ assert!(pool.partial_slots().unwrap().is_empty());
+ assert_eq!(fs::read(target.join("AGENTS.md")).unwrap(), original);
+ assert_eq!(
+ fs::read(control.join("backups/incomplete/slot-000000000001-0/payload/AGENTS.md"))
+ .unwrap(),
+ b"interrupted copy"
+ );
+ plan_then_apply(&target, "backup", &[]);
+ assert_eq!(pool.list().unwrap().len(), 1);
+ }
+
#[test]
fn a_restore_plan_names_the_target_it_will_produce() {
let target = seeded("restore-shape");
diff --git a/crates/provider-v3/src/argv.rs b/crates/provider-v3/src/argv.rs
index 5457be0..aa95412 100644
--- a/crates/provider-v3/src/argv.rs
+++ b/crates/provider-v3/src/argv.rs
@@ -101,6 +101,10 @@ const fn plan_usage(command: Command) -> Usage {
"where a program lives; required by every software_* operation",
),
("--backup-ref", "which slot a restore returns to"),
+ (
+ "--capture-mode",
+ "complete_native captures the entire declared native surface",
+ ),
("--permission-profile", "a profile this build declares"),
(
"--software-version",
@@ -319,6 +323,8 @@ pub struct PlanRequest {
pub expires_at: String,
/// The backup this operation reads or writes.
pub backup_ref: Option,
+ /// Explicit complete preservation; absent retains recorded-file backup behavior.
+ pub capture_mode: Option,
/// The permission profile to apply.
pub permission_profile: Option,
/// The bundle, when the operation carries one.
@@ -509,6 +515,7 @@ where
operation_id: flags.take_required("--operation-id")?,
expires_at: flags.take_required("--expires-at")?,
backup_ref: flags.take_optional("--backup-ref"),
+ capture_mode: flags.take_optional("--capture-mode"),
permission_profile: flags.take_optional("--permission-profile"),
bundle: flags.take_bundle()?,
prefix: flags.take_prefix()?,
diff --git a/crates/provider-v3/src/info.rs b/crates/provider-v3/src/info.rs
index b3e4ad4..b94fda9 100644
--- a/crates/provider-v3/src/info.rs
+++ b/crates/provider-v3/src/info.rs
@@ -408,6 +408,7 @@ impl ProviderInfo {
plan_request_fields: vec![
TargetScope::REQUEST_FIELD.to_owned(),
EndState::REQUEST_FIELD.to_owned(),
+ "capture_mode".to_owned(),
],
// Declared 2026-09-02 in the same order as the two above: kit 0.2.9
// names the member (`provider-info.schema.json`), ai-stp-cli
diff --git a/crates/provider-v3/src/plan.rs b/crates/provider-v3/src/plan.rs
index 6d30a99..597abec 100644
--- a/crates/provider-v3/src/plan.rs
+++ b/crates/provider-v3/src/plan.rs
@@ -153,6 +153,22 @@ impl EndState {
}
}
+/// The provider's immutable description of one effect.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NativeCapture {
+ /// Explicit base of the complete cover, never an arbitrary ancestor.
+ #[serde(default)]
+ pub base_root: setup_core::native_snapshot::NativeBase,
+ /// Complete native namespace coverage, including shared configuration.
+ pub roots: Vec,
+ /// Product-owned paths outside the effect.
+ pub excluded: Vec,
+ /// Complete current-state precondition, including permission metadata.
+ pub current_digest: String,
+ /// Complete restored-state identity when returning to a preserved setup.
+ pub restore_digest: Option,
+}
+
/// The provider's immutable description of one effect.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PlanArtifact {
@@ -184,6 +200,9 @@ pub struct PlanArtifact {
pub backup_ref: Option,
/// The target identity a restore will produce. Restore only.
pub restore_target_digest: Option,
+ /// Complete native preservation, explicitly requested or inherited from a snapshot.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub native_capture: Option,
/// The permission profile to apply, when one was requested.
pub permission_profile: Option,
/// The scope the consumer resolved this target to be, when it said.
@@ -268,6 +287,8 @@ pub struct PlanInputs<'a> {
pub backup_ref: Option,
/// The identity a restore will produce. Required for restore, refused otherwise.
pub restore_target_digest: Option,
+ /// Complete native preservation binding when requested.
+ pub native_capture: Option,
/// The permission profile, when one was requested.
pub permission_profile: Option,
/// The scope the consumer resolved this target to be, when it said.
@@ -389,6 +410,7 @@ impl PlanArtifact {
bundle: inputs.bundle,
backup_ref: inputs.backup_ref,
restore_target_digest: inputs.restore_target_digest,
+ native_capture: inputs.native_capture,
permission_profile: inputs.permission_profile,
platform: platform::echo(),
expires_at: inputs.expires_at.to_owned(),
@@ -542,6 +564,7 @@ mod tests {
bundle: Some(binding()),
backup_ref: Some("slot-000000000001".to_owned()),
restore_target_digest: None,
+ native_capture: None,
permission_profile: Some("default".to_owned()),
expires_at: "2026-08-23T15:00:00Z",
effects: vec!["write settings.json".to_owned()],
diff --git a/crates/setup-core/src/backup.rs b/crates/setup-core/src/backup.rs
index 48c47d9..3bacfb3 100644
--- a/crates/setup-core/src/backup.rs
+++ b/crates/setup-core/src/backup.rs
@@ -47,6 +47,9 @@ pub const SLOT_HELD_NAME: &str = "HELD";
/// The schema this kernel writes and is willing to read.
pub const SLOT_SCHEMA: u32 = 1;
+/// Complete native snapshots require a reader that understands their coverage.
+pub const NATIVE_SLOT_SCHEMA: u32 = 2;
+
/// A target-bound reference to one backup slot.
///
/// The reference is meaningful only against the target it was captured from;
@@ -122,6 +125,15 @@ pub struct SlotRecord {
/// refusing to read one would trade a recoverable target for a field.
#[serde(default)]
pub setup_definition_digest: Option,
+ /// Verified complete native coverage, absent for write-only backups.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub native_snapshot: Option,
+ /// Installation ownership before capture, independent of preserved coverage.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub previous_written_paths: Option>,
+ /// Exact pre-operation provider metadata for complete recovery.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub previous_provider_state: Option>,
}
/// The bounded pool of backup slots for one target.
@@ -154,6 +166,17 @@ impl Pool {
)
.with_source(source)
})?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).map_err(|source| {
+ Error::new(
+ ReasonCode::StateUnavailable,
+ "cannot protect local recovery storage",
+ )
+ .with_source(source)
+ })?;
+ }
Ok(Self { root, capacity })
}
@@ -226,6 +249,53 @@ impl Pool {
Ok(partial)
}
+ /// Move incomplete captures aside without discarding their bytes.
+ ///
+ /// The caller must hold the target lock and establish that no journal is
+ /// published. An unpublished capture cannot have authorized target writes.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReasonCode::StateUnavailable`] if listing or renaming fails.
+ pub fn quarantine_partial(&self) -> Result {
+ let partial = self.partial_slots()?;
+ if partial.is_empty() {
+ return Ok(0);
+ }
+ let archive = self.root.join("incomplete");
+ fs::create_dir_all(&archive).map_err(|source| {
+ Error::new(
+ ReasonCode::StateUnavailable,
+ "cannot create incomplete capture archive",
+ )
+ .with_source(source)
+ })?;
+ for slot in &partial {
+ let name = slot
+ .file_name()
+ .ok_or_else(|| {
+ Error::new(ReasonCode::IntegrityMismatch, "a capture slot has no name")
+ })?
+ .to_string_lossy();
+ let mut sequence = 0_u64;
+ let destination = loop {
+ let candidate = archive.join(format!("{name}-{sequence}"));
+ if !candidate.exists() {
+ break candidate;
+ }
+ sequence += 1;
+ };
+ fs::rename(slot, destination).map_err(|source| {
+ Error::new(
+ ReasonCode::StateUnavailable,
+ "cannot quarantine incomplete capture",
+ )
+ .with_source(source)
+ })?;
+ }
+ Ok(partial.len())
+ }
+
/// The payload directory of one completed slot.
///
/// # Errors
@@ -240,7 +310,23 @@ impl Pool {
format!("backup {} is absent or incomplete", backup_ref.as_str()),
));
}
- Ok(slot.join(SLOT_PAYLOAD_NAME))
+ let payload = slot.join(SLOT_PAYLOAD_NAME);
+ let record = read_record(&slot)?.ok_or_else(|| {
+ Error::new(
+ ReasonCode::IntegrityMismatch,
+ "backup completion record is absent",
+ )
+ })?;
+ if record.backup_ref != *backup_ref {
+ return Err(Error::new(
+ ReasonCode::IntegrityMismatch,
+ "backup reference does not match its slot",
+ ));
+ }
+ if let Some(snapshot) = record.native_snapshot {
+ snapshot.verify(&payload)?;
+ }
+ Ok(payload)
}
/// The reference the next capture will use.
@@ -328,7 +414,40 @@ impl Pool {
}
}
- let record = record(backup_ref);
+ self.complete(&slot, record(backup_ref))
+ }
+
+ /// Capture and read back a complete native snapshot before publishing its marker.
+ ///
+ /// # Errors
+ /// Refuses changed source state, failed copies and verification or retention errors.
+ pub fn capture_native(
+ &self,
+ source: &Path,
+ snapshot: &crate::native_snapshot::NativeSnapshot,
+ record: impl FnOnce(BackupRef) -> SlotRecord,
+ ) -> Result {
+ let backup_ref = self.next_ref()?;
+ let slot = self.root.join(backup_ref.as_str());
+ let payload = slot.join(SLOT_PAYLOAD_NAME);
+ fs::create_dir_all(&payload).map_err(|error| {
+ Error::new(
+ ReasonCode::StateUnavailable,
+ "cannot create native backup payload",
+ )
+ .with_source(error)
+ })?;
+ snapshot.copy_to(source, &payload)?;
+ let mut record = record(backup_ref);
+ record.schema_version = NATIVE_SLOT_SCHEMA;
+ record.native_snapshot = Some(snapshot.clone());
+ // Publish retention before completion: a completed preserved setup must
+ // never enter the reclaimable rolling window, even after a lost response.
+ lock::atomic_write(&slot.join(SLOT_HELD_NAME), b"preserved native setup")?;
+ self.complete(&slot, record)
+ }
+
+ fn complete(&self, slot: &Path, record: SlotRecord) -> Result {
let value = serde_json::to_value(&record).map_err(|source_error| {
Error::new(
ReasonCode::StateUnavailable,
@@ -359,11 +478,8 @@ impl Pool {
///
/// # Errors
///
- /// Refuses a reference this pool does not hold, and refuses a hold that
- /// would leave the pool no slot to rotate — ten held slots is a target that
- /// can never be backed up again, which is a worse failure than the eviction
- /// it was protecting against. The refusal names what is already held so a
- /// caller knows what to release.
+ /// Refuses a reference this pool does not hold or an unreadable hold marker.
+ /// Held snapshots are outside the rolling capacity and remain until released.
pub fn hold(&self, backup_ref: &BackupRef, reason: &str) -> Result {
let slot = self.root.join(backup_ref.as_str());
if read_record(&slot)?.is_none() {
@@ -379,24 +495,6 @@ impl Pool {
if already.iter().any(|(held, _)| held == backup_ref) {
return Ok(false);
}
- // One slot must stay reclaimable, or the next capture has nothing to
- // evict and the pool grows past the bound it was opened with.
- if already.len() + 1 >= self.capacity {
- return Err(Error::new(
- ReasonCode::InvalidTarget,
- format!(
- "holding {} would leave this pool of {} no slot to rotate; release one of \
- these first, and the reason each names is who would lose it: {}",
- backup_ref.as_str(),
- self.capacity,
- already
- .iter()
- .map(|(held, why)| format!("{} ({why})", held.as_str()))
- .collect::>()
- .join(", ")
- ),
- ));
- }
// The reason travels with the hold. Without it a caller reading a full
// pool knows what to release and not what releasing it would cost.
fs::write(slot.join(SLOT_HELD_NAME), reason.as_bytes()).map_err(|source| {
@@ -574,11 +672,16 @@ fn read_record(slot: &Path) -> Result> {
)
.with_source(source)
})?;
- if record.schema_version != SLOT_SCHEMA {
+ let valid = match record.schema_version {
+ SLOT_SCHEMA => record.native_snapshot.is_none(),
+ NATIVE_SLOT_SCHEMA => record.native_snapshot.is_some(),
+ _ => false,
+ };
+ if !valid {
return Err(Error::new(
ReasonCode::StateUnavailable,
format!(
- "backup schema {} is not the {SLOT_SCHEMA} this build writes",
+ "backup schema {} does not match a supported recovery record",
record.schema_version
),
));
@@ -805,9 +908,34 @@ mod tests {
target_identity_digest: "sha256:target".to_owned(),
setup_id: Some("full-auto".to_owned()),
setup_definition_digest: Some("sha256:definition".to_owned()),
+ native_snapshot: None,
+ previous_written_paths: None,
+ previous_provider_state: None,
}
}
+ #[test]
+ fn complete_native_slots_verify_payload_integrity_on_read() {
+ let root = scratch("native-integrity");
+ let source = root.join("source");
+ fs::create_dir_all(source.join("skills/empty")).unwrap();
+ fs::write(source.join("skills/tool.sh"), b"echo captured\n").unwrap();
+ let snapshot =
+ crate::native_snapshot::NativeSnapshot::inspect(&source, &["skills"], &[]).unwrap();
+ let pool = Pool::open(&root.join("control"), 3).unwrap();
+ let record = pool.capture_native(&source, &snapshot, record_for).unwrap();
+ assert_eq!(record.schema_version, NATIVE_SLOT_SCHEMA);
+ assert_eq!(record.native_snapshot, Some(snapshot));
+ let payload = pool.payload_of(&record.backup_ref).unwrap();
+ assert!(payload.join("skills/empty").is_dir());
+ fs::write(payload.join("skills/tool.sh"), b"corrupted").unwrap();
+ assert!(pool.payload_of(&record.backup_ref).is_err());
+ assert_eq!(
+ fs::read(source.join("skills/tool.sh")).unwrap(),
+ b"echo captured\n"
+ );
+ }
+
#[test]
fn a_slot_written_before_the_definition_digest_existed_still_reads() {
// The field was added without a schema bump, so a slot captured by an
@@ -905,38 +1033,27 @@ mod tests {
assert!(pool.payload_of(&baseline.backup_ref).is_err());
}
- /// A pool that is entirely held is a target that can never be backed up
- /// again, which is a worse failure than the eviction a hold prevents.
#[test]
- fn a_hold_that_would_leave_nothing_to_rotate_is_refused_naming_what_to_release() {
+ fn preserved_slots_do_not_consume_the_rolling_capacity() {
let base = scratch("held-full");
let target = base.join("target");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("a.txt"), "x").unwrap();
-
let pool = Pool::open(&base.join("control"), 3).unwrap();
- let first = pool.capture(&target, &["a.txt"], record_for).unwrap();
- let second = pool.capture(&target, &["a.txt"], record_for).unwrap();
- let third = pool.capture(&target, &["a.txt"], record_for).unwrap();
-
- assert!(pool.hold(&first.backup_ref, "series A baseline").unwrap());
- assert!(pool.hold(&second.backup_ref, "series B baseline").unwrap());
- // Holding a third of three would leave nothing to evict.
- let error = pool.hold(&third.backup_ref, "series C").unwrap_err();
- assert!(error.to_string().contains("no slot to rotate"), "{error}");
- assert!(
- error.to_string().contains(first.backup_ref.as_str()),
- "the refusal does not say what to release: {error}"
- );
- // And what releasing it would cost, so nobody releases blind.
- assert!(
- error.to_string().contains("series A baseline"),
- "the refusal does not say who holds it: {error}"
- );
-
- // Holding one that is already held is not an error and not a second
- // hold: a run that re-runs its own setup should not have to check.
- assert!(!pool.hold(&first.backup_ref, "series A again").unwrap());
+ let mut saved = Vec::new();
+ for _ in 0..7 {
+ let record = pool.capture(&target, &["a.txt"], record_for).unwrap();
+ assert!(pool.hold(&record.backup_ref, "saved setup").unwrap());
+ saved.push(record.backup_ref);
+ }
+ for _ in 0..8 {
+ pool.capture(&target, &["a.txt"], record_for).unwrap();
+ }
+ assert_eq!(pool.list().unwrap().len(), saved.len() + 3);
+ for reference in saved {
+ assert!(pool.payload_of(&reference).is_ok());
+ assert!(!pool.hold(&reference, "retry").unwrap());
+ }
}
/// A reference this pool never minted is refused rather than marked.
diff --git a/crates/setup-core/src/lib.rs b/crates/setup-core/src/lib.rs
index 1e3f7c6..f277754 100644
--- a/crates/setup-core/src/lib.rs
+++ b/crates/setup-core/src/lib.rs
@@ -25,6 +25,7 @@ pub mod digest;
pub mod error;
pub mod journal;
pub mod lock;
+pub mod native_snapshot;
pub mod software;
pub mod stamp;
pub mod target;
diff --git a/crates/setup-core/src/native_snapshot.rs b/crates/setup-core/src/native_snapshot.rs
new file mode 100644
index 0000000..d5a62ca
--- /dev/null
+++ b/crates/setup-core/src/native_snapshot.rs
@@ -0,0 +1,464 @@
+//! Complete local native configuration snapshots, separate from write ownership.
+
+use std::collections::BTreeMap;
+use std::fs;
+use std::path::{Component, Path};
+
+use serde::{Deserialize, Serialize};
+
+use crate::{Error, ReasonCode, Result};
+
+/// Filesystem base of a declared preservation cover.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum NativeBase {
+ /// Every covered path is relative to the provider target.
+ #[default]
+ Target,
+ /// A closed cover includes a documented companion beside the target.
+ Parent,
+}
+
+impl NativeBase {
+ #[allow(
+ clippy::trivially_copy_pass_by_ref,
+ reason = "serde skip callback takes a reference"
+ )]
+ fn is_target(&self) -> bool {
+ *self == Self::Target
+ }
+}
+
+/// The complete configuration surface and its measured members.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NativeSnapshot {
+ /// Base for the declared relative roots, included in the snapshot digest.
+ #[serde(default, skip_serializing_if = "NativeBase::is_target")]
+ pub base_root: NativeBase,
+ /// Target-relative native namespace roots, reduced to a non-overlapping cover.
+ pub roots: Vec,
+ /// Product-owned paths excluded from capture and replacement.
+ pub excluded: Vec,
+ /// Exact relative member paths and metadata; no raw configuration values.
+ pub entries: BTreeMap,
+}
+
+/// A regular file or directory captured without following links.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Member {
+ /// File content digest; absent for a directory.
+ pub digest: Option,
+ /// Unix permission bits, or the read-only flag on other platforms.
+ pub permissions: u32,
+}
+
+fn io_error(path: &Path, source: std::io::Error) -> Error {
+ Error::new(
+ ReasonCode::StateUnavailable,
+ format!("cannot access native snapshot member {}", path.display()),
+ )
+ .with_source(source)
+}
+
+fn within(path: &str, root: &str) -> bool {
+ path == root
+ || path
+ .strip_prefix(root)
+ .is_some_and(|rest| rest.starts_with('/'))
+}
+
+fn validate_path(path: &str) -> Result<()> {
+ if path.is_empty()
+ || path.contains('\\')
+ || path
+ .split('/')
+ .any(|part| part.is_empty() || part == "." || part == "..")
+ || Path::new(path)
+ .components()
+ .any(|part| !matches!(part, Component::Normal(_)))
+ {
+ return Err(Error::new(
+ ReasonCode::IntegrityMismatch,
+ "invalid native snapshot relative path",
+ ));
+ }
+ Ok(())
+}
+
+fn permissions(metadata: &fs::Metadata) -> u32 {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ metadata.permissions().mode() & 0o7777
+ }
+ #[cfg(not(unix))]
+ {
+ u32::from(metadata.permissions().readonly())
+ }
+}
+
+fn set_permissions(path: &Path, mode: u32) -> Result<()> {
+ #[cfg(unix)]
+ let value = {
+ use std::os::unix::fs::PermissionsExt;
+ fs::Permissions::from_mode(mode)
+ };
+ #[cfg(not(unix))]
+ let value = {
+ let mut value = fs::metadata(path)
+ .map_err(|error| io_error(path, error))?
+ .permissions();
+ value.set_readonly(mode != 0);
+ value
+ };
+ fs::set_permissions(path, value).map_err(|error| io_error(path, error))
+}
+
+impl NativeSnapshot {
+ /// Measure every member of a native surface without following filesystem links.
+ ///
+ /// # Errors
+ /// Refuses unsupported entries, unreadable members and invalid relative paths.
+ pub fn inspect(root: &Path, roots: &[&str], excluded: &[&str]) -> Result {
+ let mut roots: Vec = roots.iter().map(|path| (*path).to_owned()).collect();
+ let mut excluded: Vec = excluded.iter().map(|path| (*path).to_owned()).collect();
+ for path in roots.iter().chain(&excluded) {
+ validate_path(path)?;
+ }
+ roots.sort();
+ roots.dedup();
+ excluded.sort();
+ excluded.dedup();
+ let cover = roots
+ .iter()
+ .filter(|path| {
+ !roots
+ .iter()
+ .any(|ancestor| ancestor != *path && within(path, ancestor))
+ && !excluded.iter().any(|skip| within(path, skip))
+ })
+ .cloned()
+ .collect();
+ let mut snapshot = Self {
+ base_root: NativeBase::Target,
+ roots: cover,
+ excluded,
+ entries: BTreeMap::new(),
+ };
+ for relative in snapshot.roots.clone() {
+ // A nested declaration must not cross a symlink in a transport parent.
+ let mut parent = root.to_path_buf();
+ for component in Path::new(&relative).components() {
+ parent.push(component);
+ match fs::symlink_metadata(&parent) {
+ Ok(meta) if meta.is_symlink() => {
+ return Err(Error::new(
+ ReasonCode::IntegrityMismatch,
+ "native snapshot refuses symbolic links",
+ ));
+ }
+ Ok(_) => (),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
+ Err(error) => return Err(io_error(&parent, error)),
+ }
+ }
+ snapshot.walk(root, &relative)?;
+ }
+ Ok(snapshot)
+ }
+
+ fn walk(&mut self, root: &Path, relative: &str) -> Result<()> {
+ if self.excluded.iter().any(|skip| within(relative, skip)) {
+ return Ok(());
+ }
+ let path = root.join(relative);
+ let metadata = match fs::symlink_metadata(&path) {
+ Ok(value) => value,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
+ Err(error) => return Err(io_error(&path, error)),
+ };
+ if !metadata.is_dir() && !metadata.is_file() {
+ return Err(Error::new(
+ ReasonCode::IntegrityMismatch,
+ format!("native snapshot refuses unsupported entry {relative}"),
+ ));
+ }
+ // Transport directories of excluded runtime state cannot be removed or
+ // have their mode rewound. Their covered children still belong to the
+ // snapshot, but creation of excluded state must not create native drift.
+ if !metadata.is_dir() || !self.excluded.iter().any(|skip| within(skip, relative)) {
+ self.entries.insert(
+ relative.to_owned(),
+ Member {
+ digest: if metadata.is_file() {
+ Some(crate::digest::of_file(&path)?)
+ } else {
+ None
+ },
+ permissions: permissions(&metadata),
+ },
+ );
+ }
+ if metadata.is_dir() {
+ for entry in fs::read_dir(&path).map_err(|error| io_error(&path, error))? {
+ let entry = entry.map_err(|error| io_error(&path, error))?;
+ let name = entry.file_name().into_string().map_err(|_| {
+ Error::new(
+ ReasonCode::IntegrityMismatch,
+ "native snapshot member name is not UTF-8",
+ )
+ })?;
+ validate_path(&name)?;
+ self.walk(root, &format!("{relative}/{name}"))?;
+ }
+ }
+ Ok(())
+ }
+
+ /// A digest of coverage, member bytes and permission metadata.
+ ///
+ /// # Errors
+ /// Propagates canonical serialization failures.
+ pub fn digest(&self) -> Result {
+ let value = serde_json::to_value(self).map_err(|error| {
+ Error::new(
+ ReasonCode::IntegrityMismatch,
+ "cannot encode native snapshot",
+ )
+ .with_source(error)
+ })?;
+ crate::digest::of_domain_canonical_json("nddev.native-snapshot.v1", &value)
+ }
+
+ /// Remeasure the whole surface and require an exact match.
+ ///
+ /// # Errors
+ /// Refuses missing, extra, changed, unreadable or unsupported members.
+ pub fn verify(&self, root: &Path) -> Result<()> {
+ let mut measured = Self::inspect(
+ root,
+ &self.roots.iter().map(String::as_str).collect::>(),
+ &self.excluded.iter().map(String::as_str).collect::>(),
+ )?;
+ measured.base_root = self.base_root;
+ if measured != *self {
+ return Err(Error::new(
+ ReasonCode::IntegrityMismatch,
+ "native snapshot inventory does not match",
+ ));
+ }
+ Ok(())
+ }
+
+ /// Copy measured members and preserve supported file and directory permissions.
+ ///
+ /// # Errors
+ /// Refuses changed source state and failed copies or readback verification.
+ pub fn copy_to(&self, source: &Path, destination: &Path) -> Result<()> {
+ self.verify(source)?;
+ for (relative, member) in &self.entries {
+ let from = source.join(relative);
+ let to = destination.join(relative);
+ if member.digest.is_none() {
+ fs::create_dir_all(&to).map_err(|error| io_error(&to, error))?;
+ } else {
+ if let Some(parent) = to.parent() {
+ fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
+ }
+ fs::copy(&from, &to).map_err(|error| io_error(&to, error))?;
+ set_permissions(&to, member.permissions)?;
+ }
+ }
+ // Restrictive directory modes apply after their children are copied.
+ for (relative, member) in self.entries.iter().rev() {
+ if member.digest.is_none() {
+ set_permissions(&destination.join(relative), member.permissions)?;
+ }
+ }
+ self.verify(source)?;
+ self.verify(destination)
+ }
+
+ /// Replace the covered surface after a caller has preserved its current state.
+ ///
+ /// # Errors
+ /// Refuses invalid recovery bytes before deletion and verifies the final state.
+ pub fn restore(&self, payload: &Path, target: &Path) -> Result<()> {
+ self.verify(payload)?;
+ let current = Self::inspect(
+ target,
+ &self.roots.iter().map(String::as_str).collect::>(),
+ &self.excluded.iter().map(String::as_str).collect::>(),
+ )?;
+ for (relative, member) in current.entries.iter().rev() {
+ let path = target.join(relative);
+ if member.digest.is_none() {
+ match fs::remove_dir(&path) {
+ Ok(()) => (),
+ Err(error)
+ if error.kind() == std::io::ErrorKind::DirectoryNotEmpty
+ && self.excluded.iter().any(|skip| within(skip, relative)) => {}
+ Err(error) => return Err(io_error(&path, error)),
+ }
+ } else {
+ fs::remove_file(&path).map_err(|error| io_error(&path, error))?;
+ }
+ }
+ self.copy_to(payload, target)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, clippy::panic)]
+
+ use super::*;
+
+ fn scratch(name: &str) -> std::path::PathBuf {
+ let path =
+ std::env::temp_dir().join(format!("native-snapshot-{name}-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&path);
+ fs::create_dir_all(&path).unwrap();
+ path
+ }
+
+ #[test]
+ fn restore_returns_complete_state_and_preserves_the_state_it_replaces() {
+ let root = scratch("return");
+ let target = root.join("target");
+ let original = root.join("original");
+ let edited = root.join("edited");
+ fs::create_dir_all(target.join("skills/empty")).unwrap();
+ fs::write(target.join("skills/run.py"), b"print('original')\n").unwrap();
+ fs::write(target.join("config.json"), b"{\"x\":1}\n").unwrap();
+ fs::write(target.join("project.txt"), b"outside").unwrap();
+ let baseline = NativeSnapshot::inspect(&target, &["skills", "config.json"], &[]).unwrap();
+ fs::create_dir_all(&original).unwrap();
+ baseline.copy_to(&target, &original).unwrap();
+ fs::write(target.join("config.json"), b"changed").unwrap();
+ fs::write(target.join("skills/user-added.sh"), b"echo user\n").unwrap();
+ fs::remove_dir(target.join("skills/empty")).unwrap();
+ let current = NativeSnapshot::inspect(&target, &["skills", "config.json"], &[]).unwrap();
+ fs::create_dir_all(&edited).unwrap();
+ current.copy_to(&target, &edited).unwrap();
+ baseline.restore(&original, &target).unwrap();
+ baseline.verify(&target).unwrap();
+ assert!(target.join("skills/empty").is_dir());
+ assert!(!target.join("skills/user-added.sh").exists());
+ assert_eq!(fs::read(target.join("project.txt")).unwrap(), b"outside");
+ current.restore(&edited, &target).unwrap();
+ current.verify(&target).unwrap();
+ fs::write(target.join("skills/unexpected"), b"extra").unwrap();
+ assert!(current.verify(&target).is_err());
+ }
+
+ #[test]
+ fn runtime_creation_does_not_change_an_empty_native_surface() {
+ let root = scratch("runtime-parent");
+ let target = root.join("target");
+ let payload = root.join("payload");
+ fs::create_dir_all(&target).unwrap();
+ fs::create_dir_all(&payload).unwrap();
+ let empty = NativeSnapshot::inspect(&target, &["plugins"], &["plugins/data"]).unwrap();
+ empty.copy_to(&target, &payload).unwrap();
+ fs::create_dir_all(target.join("plugins/data")).unwrap();
+ fs::write(target.join("plugins/data/session"), b"current runtime").unwrap();
+ empty.verify(&target).unwrap();
+ fs::create_dir_all(target.join("plugins/cache/new")).unwrap();
+ fs::write(target.join("plugins/cache/new/code.js"), b"new config").unwrap();
+ assert!(empty.verify(&target).is_err());
+ empty.restore(&payload, &target).unwrap();
+ empty.verify(&target).unwrap();
+ assert_eq!(
+ fs::read(target.join("plugins/data/session")).unwrap(),
+ b"current runtime"
+ );
+ assert!(!target.join("plugins/cache").exists());
+ }
+
+ #[test]
+ fn empty_original_removes_later_native_configuration() {
+ let root = scratch("empty");
+ let target = root.join("target");
+ let payload = root.join("payload");
+ fs::create_dir_all(&target).unwrap();
+ fs::create_dir_all(&payload).unwrap();
+ let original = NativeSnapshot::inspect(&target, &["skills", "config.json"], &[]).unwrap();
+ original.copy_to(&target, &payload).unwrap();
+ fs::create_dir_all(target.join("skills/new")).unwrap();
+ fs::write(target.join("config.json"), b"new").unwrap();
+ original.restore(&payload, &target).unwrap();
+ original.verify(&target).unwrap();
+ assert_eq!(fs::read_dir(&target).unwrap().count(), 0);
+ }
+
+ #[test]
+ fn corrupt_payload_is_refused_before_target_changes() {
+ let root = scratch("corrupt");
+ let target = root.join("target");
+ let payload = root.join("payload");
+ fs::create_dir_all(&target).unwrap();
+ fs::create_dir_all(&payload).unwrap();
+ fs::write(target.join("config.json"), b"original").unwrap();
+ let snapshot = NativeSnapshot::inspect(&target, &["config.json"], &[]).unwrap();
+ snapshot.copy_to(&target, &payload).unwrap();
+ fs::write(payload.join("config.json"), b"corrupt").unwrap();
+ fs::write(target.join("config.json"), b"user edit").unwrap();
+ assert!(snapshot.restore(&payload, &target).is_err());
+ assert_eq!(fs::read(target.join("config.json")).unwrap(), b"user edit");
+ }
+
+ #[test]
+ fn never_touch_files_are_neither_copied_nor_replaced() {
+ let root = scratch("excluded");
+ let target = root.join("target");
+ let payload = root.join("payload");
+ fs::create_dir_all(target.join("skills")).unwrap();
+ fs::create_dir_all(&payload).unwrap();
+ fs::write(target.join("skills/session.json"), b"product-owned").unwrap();
+ fs::write(target.join("skills/a.md"), b"component").unwrap();
+ let snapshot =
+ NativeSnapshot::inspect(&target, &["skills"], &["skills/session.json"]).unwrap();
+ snapshot.copy_to(&target, &payload).unwrap();
+ assert!(!payload.join("skills/session.json").exists());
+ fs::write(target.join("skills/session.json"), b"updated by product").unwrap();
+ fs::write(target.join("skills/new.md"), b"new").unwrap();
+ snapshot.restore(&payload, &target).unwrap();
+ assert_eq!(
+ fs::read(target.join("skills/session.json")).unwrap(),
+ b"updated by product"
+ );
+ assert!(!target.join("skills/new.md").exists());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn permissions_are_restored_and_part_of_preconditions() {
+ let root = scratch("permissions");
+ let target = root.join("target");
+ let payload = root.join("payload");
+ fs::create_dir_all(target.join("skills/empty")).unwrap();
+ fs::create_dir_all(&payload).unwrap();
+ fs::write(target.join("skills/tool"), b"tool").unwrap();
+ set_permissions(&target.join("skills/tool"), 0o750).unwrap();
+ set_permissions(&target.join("skills/empty"), 0o700).unwrap();
+ let snapshot = NativeSnapshot::inspect(&target, &["skills"], &[]).unwrap();
+ snapshot.copy_to(&target, &payload).unwrap();
+ set_permissions(&target.join("skills/tool"), 0o700).unwrap();
+ assert!(snapshot.verify(&target).is_err());
+ snapshot.restore(&payload, &target).unwrap();
+ snapshot.verify(&target).unwrap();
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn links_and_linked_transport_parents_are_refused() {
+ let root = scratch("links");
+ fs::create_dir_all(root.join("outside")).unwrap();
+ fs::write(root.join("outside/tool"), b"foreign").unwrap();
+ std::os::unix::fs::symlink(root.join("outside"), root.join("skills")).unwrap();
+ assert!(NativeSnapshot::inspect(&root, &["skills"], &[]).is_err());
+ assert!(NativeSnapshot::inspect(&root, &["skills/tool"], &[]).is_err());
+ assert!(NativeSnapshot::inspect(&root, &["../outside"], &[]).is_err());
+ }
+}
diff --git a/install.ps1 b/install.ps1
index 82d2658..a47c56f 100644
--- a/install.ps1
+++ b/install.ps1
@@ -7,7 +7,7 @@
# powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.1.0
[CmdletBinding()]
param(
- [string]$Version = "0.0.67",
+ [string]$Version = "0.0.68",
[string]$InstallDir = "$env:LOCALAPPDATA\Programs\cursor-setup-system"
)
$ErrorActionPreference = "Stop"
diff --git a/install.sh b/install.sh
index 7bfe1be..b838e9f 100644
--- a/install.sh
+++ b/install.sh
@@ -14,7 +14,7 @@ set -eu
REPO="NDDev-OpenNetwork/cursor-setup-system"
BINARY="cursor-setup-system"
-VERSION="${1:-0.0.67}"
+VERSION="${1:-0.0.68}"
PREFIX="${CURSOR_INSTALL_DIR:-$HOME/.local/bin}"
case "$(uname -s)" in
diff --git a/provider-kit/v3/KIT-IDENTITY.json b/provider-kit/v3/KIT-IDENTITY.json
index 4e46a75..ca45c47 100644
--- a/provider-kit/v3/KIT-IDENTITY.json
+++ b/provider-kit/v3/KIT-IDENTITY.json
@@ -1,12 +1,12 @@
{
- "aggregate_digest": "sha256:0578235bbec7bb23c6643651b6f105826d3a05a01bea0bd0cffac940b9cea4f3",
+ "aggregate_digest": "sha256:6ab195790be1c7dfacf6e6ab5a892244097cc2002f97090ff9a4b3c3174ed357",
"files": [
"conformance-cases.json",
"manifest.json",
"provider-info.schema.json",
"status-response.schema.json"
],
- "kit_version": "0.2.10",
+ "kit_version": "0.2.11",
"protocol_version": 3,
"schema": "ai-stp-provider-kit-identity/1"
}
diff --git a/provider-kit/v3/SHA256SUMS b/provider-kit/v3/SHA256SUMS
index 83a729b..6f9f240 100644
--- a/provider-kit/v3/SHA256SUMS
+++ b/provider-kit/v3/SHA256SUMS
@@ -1,4 +1,4 @@
fe04d03b15cfe8d5b61835eafd3ea788074684e2be69d419f9c22a37ea0461b7 conformance-cases.json
-7dbec741b60de09250e11a9b144eccb73445c029db8f82797c8f9c62cf22b36b manifest.json
-9df9ceb257fb421713678e8d516ac4f7095df87d675c74801d6cc2cf4e8f24ee provider-info.schema.json
-2c5e01df3b02369832b74842b128c42b6b84d1e1455db3800474e54e71b645e4 status-response.schema.json
+ae323ef26648fbaf441aa179008d0899ec79af88b3ae1e738e5bc75b1bbc8f30 manifest.json
+64514da0c61012b5323987d72ee75f5ce8d6451ed557331a634b17aeac8d8966 provider-info.schema.json
+7e67760f24bbc42a1242dad7ad3b378eef7ae15023c6813a33d582dfd2232293 status-response.schema.json
diff --git a/provider-kit/v3/manifest.json b/provider-kit/v3/manifest.json
index a191b09..9a4afcc 100644
--- a/provider-kit/v3/manifest.json
+++ b/provider-kit/v3/manifest.json
@@ -39,7 +39,7 @@
],
"decision": "docs/adr/ADR-0061-capability-negotiated-provider-protocol-v3.md",
"generated_from": "apps/cli/src/ai_stp_cli/provider/protocol_v3.py",
- "kit_version": "0.2.10",
+ "kit_version": "0.2.11",
"operation_network": {
"backup": [
{
diff --git a/provider-kit/v3/provider-info.schema.json b/provider-kit/v3/provider-info.schema.json
index 128b324..53ba00d 100644
--- a/provider-kit/v3/provider-info.schema.json
+++ b/provider-kit/v3/provider-info.schema.json
@@ -18,6 +18,7 @@
"plan_request_fields": {
"items": {
"enum": [
+ "capture_mode",
"end_state",
"target_scope"
],
diff --git a/provider-kit/v3/status-response.schema.json b/provider-kit/v3/status-response.schema.json
index f28f384..4f38c9c 100644
--- a/provider-kit/v3/status-response.schema.json
+++ b/provider-kit/v3/status-response.schema.json
@@ -136,6 +136,60 @@
}
]
},
+ "native_snapshot": {
+ "additionalProperties": false,
+ "properties": {
+ "base_root": {
+ "enum": [
+ "target",
+ "parent"
+ ]
+ },
+ "digest": {
+ "$ref": "#/$defs/digest"
+ },
+ "excluded": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "operation_id": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "roots": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "target_state": {
+ "enum": [
+ "matches",
+ "differs",
+ "unavailable"
+ ]
+ },
+ "verification": {
+ "enum": [
+ "verified",
+ "unavailable"
+ ]
+ }
+ },
+ "required": [
+ "digest",
+ "operation_id",
+ "roots",
+ "excluded",
+ "verification",
+ "target_state"
+ ],
+ "type": "object"
+ },
"operation": {
"minLength": 1,
"type": "string"