diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5a1c92d..8de11dd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,18 @@ on:
branches: [main]
push:
branches: [main]
+ # There was no way to ask this repository for a run, and on 2026-08-26 that
+ # mattered: GitHub dropped push events for about two hours, and six of the
+ # seven trees were left with no completed `ci` on the `main` they had just
+ # merged. The code was proven -- the same contexts were green on the pull
+ # request minutes earlier -- but nothing on `main` said so, and the only way
+ # to ask again was to push a commit that changed nothing.
+ #
+ # A repository whose proof can only be requested by changing it is a
+ # repository that invites empty commits. The monorepo has carried this
+ # trigger since its gate was built; the rendered trees did not, for no reason
+ # anyone chose.
+ workflow_dispatch:
permissions:
contents: read
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 18d83fc..919d0db 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -8,6 +8,9 @@ on:
schedule:
# Weekly, so an advisory published after the last push is still seen.
- cron: '17 4 * * 1'
+ # Same reason as `ci`: an advisory worth re-checking now, or a scheduled run
+ # that never started, should not require a commit to ask about.
+ workflow_dispatch:
permissions:
contents: read
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb0afde..7b711ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,46 @@ including claims a later release made false.
## [Unreleased]
+## [0.0.6] - 2026-08-27
+
+Two defects a consumer found, one this build could not see, and the
+last product that was an exception.
+
+- **`provider_plan_digest` was null after every operation of every kind.** It
+ was read out of the plan *object*, which never carries it: the digest is
+ taken over the plan and travels beside it in the planner's envelope. Reported
+ as an empty-setup defect and never about emptiness at all. It stayed
+ invisible for four releases because `status` did not publish the field, and a
+ consumer skips what is absent -- publishing what is persisted is what made it
+ a value that could be compared and refused.
+- **A backup refused a link while copying rather than before.** The slot was
+ created, files were written into it, and the walk then stopped -- a partial
+ operation and control artifacts for a shape that was knowable for free. Owned
+ paths are read before planning and again before any capture now, every
+ unsupported entry is named at once, and nothing is followed.
+- **On Windows nothing was ever exposed, so no update was ever an update.**
+ Reading which version a prefix exposes resolved the command's path, and
+ Windows writes a hard link or a copy there rather than a link -- so the answer
+ was always "nothing is exposed" and every `software_update` refused as an
+ update of nothing. Shipped in 0.0.4 and 0.0.5. The version is recorded beside
+ the command and read back now; a dangling link still exposes nothing.
+- **Pi installs like the other six.** It declared no software lifecycle on the
+ stated ground that npm resolves its closure at install time. The vendor ships
+ `npm-shrinkwrap.json`, so the closure is fixed -- and it does not matter,
+ because the published bundle imports only Node built-ins and runs with no
+ `node_modules` at all. All seven now declare the same four optional
+ operations. Its entry point is JavaScript, so Windows exposes `pi.cmd`
+ rather than a copy no platform would run.
+- **A backup can be held.** The pool rolls at ten slots, so a long series of
+ captures evicted the baseline it meant to return to. A held slot is not
+ reclaimed and is not counted against the bound, the reason is recorded beside
+ it so a full pool says who would lose what, and the last reclaimable slot
+ cannot be held -- a target that can never be backed up again is worse than
+ the eviction.
+
+Vendor versions advanced where they moved: claude 2.1.246, cursor
+2026.08.25-3e8eec8, antigravity 1.1.21.
+
## [0.0.5] - 2026-08-26
The catalog now travels with the program, and a target's identity is
diff --git a/Cargo.lock b/Cargo.lock
index 488888b..523c0a4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "harness-runtime"
-version = "0.0.5"
+version = "0.0.6"
dependencies = [
"provider-v3",
"serde",
@@ -128,7 +128,7 @@ dependencies = [
[[package]]
name = "opencode-setup-system"
-version = "0.0.5"
+version = "0.0.6"
dependencies = [
"harness-runtime",
"provider-v3",
@@ -147,7 +147,7 @@ dependencies = [
[[package]]
name = "provider-v3"
-version = "0.0.5"
+version = "0.0.6"
dependencies = [
"serde",
"serde_json",
@@ -209,7 +209,7 @@ dependencies = [
[[package]]
name = "setup-core"
-version = "0.0.5"
+version = "0.0.6"
dependencies = [
"miniz_oxide",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index 5dfc989..5f8612c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
-version = "0.0.5"
+version = "0.0.6"
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.5" }
-provider-v3 = { path = "crates/provider-v3", version = "0.0.5" }
-harness-runtime = { path = "crates/harness-runtime", version = "0.0.5" }
+setup-core = { path = "crates/setup-core", version = "0.0.6" }
+provider-v3 = { path = "crates/provider-v3", version = "0.0.6" }
+harness-runtime = { path = "crates/harness-runtime", version = "0.0.6" }
[workspace.lints.rust]
unsafe_code = "forbid"
diff --git a/README.md b/README.md
index 539486e..b312270 100644
--- a/README.md
+++ b/README.md
@@ -127,6 +127,18 @@ release's own `SHA256SUMS`, and places it at a predictable path: `~/.local/bin`
on Linux and macOS, `%LOCALAPPDATA%\Programs` on Windows. Neither needs
privilege and neither registers anything anywhere.
+Somewhere else instead:
+
+```bash
+OPENCODE_INSTALL_DIR=/opt/opencode-setup-system sh install.sh
+```
+
+The same variable on both scripts, and it is `OPENCODE_INSTALL_DIR`
+rather than the longer prefix the setup-catalog variable uses -- the installer
+is named after the product, not after the crate. It was always accepted and
+never written down here, which is how someone reading only this page installs
+into a home directory they did not mean to write to.
+
Releases carry six binaries — Linux, macOS and Windows, on x86_64 and arm64 —
which is what `provider-info` declares, so the declaration and what you can
download say the same thing.
@@ -151,7 +163,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.5 \
+ ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.6 \
status --target /config/
--json
```
diff --git a/crates/harness-runtime/src/human.rs b/crates/harness-runtime/src/human.rs
index 8f4a1e7..66a68f3 100644
--- a/crates/harness-runtime/src/human.rs
+++ b/crates/harness-runtime/src/human.rs
@@ -54,6 +54,22 @@ pub enum Command {
/// The setup to apply.
setup: String,
},
+ /// Keep one backup until it is released, so retention cannot reclaim it.
+ Hold {
+ /// The target whose pool holds it.
+ target: PathBuf,
+ /// The slot to keep. Named, never inferred.
+ backup: Option,
+ /// Why it is held, so whoever meets a full pool knows the cost.
+ reason: Option,
+ },
+ /// Let retention have a held backup back.
+ Release {
+ /// The target whose pool holds it.
+ target: PathBuf,
+ /// The slot to let go.
+ backup: Option,
+ },
/// Report which versions of the product are installed, and which is exposed.
Software {
/// The program directory to read.
@@ -123,6 +139,8 @@ pub fn is_human_command(name: &str) -> bool {
| "diff"
| "software"
| "rollback"
+ | "hold"
+ | "release"
)
}
@@ -151,6 +169,7 @@ struct Arguments {
backup: Option,
prefix: Option,
to: Option,
+ reason: Option,
positional: Vec,
}
@@ -162,13 +181,14 @@ impl Arguments {
backup: None,
prefix: None,
to: None,
+ reason: None,
positional: Vec::new(),
};
let mut index = 0;
while index < rest.len() {
let Some(token) = rest.get(index) else { break };
match token.as_str() {
- "--target" | "--backup" | "--prefix" | "--to" => {
+ "--target" | "--backup" | "--prefix" | "--to" | "--reason" => {
let Some(value) = rest.get(index + 1) else {
return Err(local(format!("{token} has no value")));
};
@@ -194,6 +214,12 @@ impl Arguments {
}
parsed.to = Some(value.clone());
}
+ "--reason" => {
+ if parsed.reason.is_some() {
+ return Err(local("--reason was given twice"));
+ }
+ parsed.reason = Some(value.clone());
+ }
_ => {
if parsed.backup.is_some() {
return Err(local("--backup was given twice"));
@@ -246,7 +272,7 @@ impl Arguments {
}
fn into_command(self, name: &str) -> Result {
- if self.backup.is_some() && name != "restore" {
+ if self.backup.is_some() && !matches!(name, "restore" | "hold" | "release") {
return Err(local(format!("--backup is not an argument of {name}")));
}
if self.prefix.is_some() && !matches!(name, "software" | "rollback") {
@@ -255,11 +281,29 @@ impl Arguments {
if self.to.is_some() && name != "rollback" {
return Err(local(format!("--to is not an argument of {name}")));
}
+ if self.reason.is_some() && name != "hold" {
+ return Err(local(format!("--reason is not an argument of {name}")));
+ }
match name {
"list" => {
self.no_setup(name)?;
Ok(Command::List)
}
+ "hold" => {
+ self.no_setup(name)?;
+ Ok(Command::Hold {
+ target: self.target(name)?,
+ backup: self.backup,
+ reason: self.reason,
+ })
+ }
+ "release" => {
+ self.no_setup(name)?;
+ Ok(Command::Release {
+ target: self.target(name)?,
+ backup: self.backup,
+ })
+ }
"software" => {
self.no_setup(name)?;
Ok(Command::Software {
@@ -351,11 +395,106 @@ pub fn run(harness: &Harness, command: Command) -> Result<()> {
Command::Restore { target, backup } => restore(harness, &target, backup),
Command::Adopt { target } => adopt_target(harness, &target),
Command::Remove { target } => remove(harness, &target),
+ Command::Hold {
+ target,
+ backup,
+ reason,
+ } => hold(harness, &target, backup.as_deref(), reason.as_deref()),
+ Command::Release { target, backup } => release(harness, &target, backup.as_deref()),
Command::Software { prefix } => software(harness, &prefix),
Command::Rollback { prefix, to } => rollback(harness, &prefix, to.as_deref()),
}
}
+/// Keep one backup until it is released.
+///
+/// The pool rolls: ten slots, oldest evicted. A long series of captures makes
+/// more than that, so a baseline someone means to return to at the end is gone
+/// by the time they get there. A hold is the smallest thing that stops it —
+/// one marker eviction has to read.
+fn hold(
+ harness: &Harness,
+ target: &Path,
+ backup: Option<&str>,
+ reason: Option<&str>,
+) -> Result<()> {
+ let (resolved, pool) = pool_of(harness, target)?;
+ let reference = named_slot(&pool, backup, "hold")?;
+ // A pool can be held by more than one run. Without a reason, whoever meets
+ // a full pool knows which slots to release and not what releasing one
+ // would cost.
+ let reason = reason.unwrap_or("no reason recorded");
+ if pool.hold(&reference, reason)? {
+ println!(
+ "{} is held ({reason}). Retention will not reclaim it until it is released.",
+ reference.as_str()
+ );
+ } else {
+ let why = pool
+ .held_reason(&reference)?
+ .unwrap_or_else(|| "no reason recorded".to_owned());
+ println!("{} was already held ({why}).", reference.as_str());
+ }
+ println!(
+ " release it with: release --backup {} --target {}",
+ reference.as_str(),
+ resolved.root().display()
+ );
+ Ok(())
+}
+
+/// Let retention have a held backup back.
+fn release(harness: &Harness, target: &Path, backup: Option<&str>) -> Result<()> {
+ let (_, pool) = pool_of(harness, target)?;
+ let reference = named_slot(&pool, backup, "release")?;
+ if pool.release(&reference)? {
+ println!(
+ "{} is released and will be reclaimed like any other slot.",
+ reference.as_str()
+ );
+ } else {
+ // Not a refusal: a run cleaning up after itself should not have to tell
+ // "nothing to do" apart from "something is wrong".
+ println!("{} was not held; nothing to release.", reference.as_str());
+ }
+ Ok(())
+}
+
+fn pool_of(harness: &Harness, target: &Path) -> Result<(Target, Pool)> {
+ let resolved = Target::resolve(target, harness.control_directory)?;
+ let pool = Pool::observe(&resolved.control_directory(), facts::BACKUP_SLOTS)?;
+ Ok((resolved, pool))
+}
+
+/// The slot a caller named, or the list of the ones they could have named.
+///
+/// Never inferred. `restore` may default to the newest because that is the one
+/// thing a caller wanting "undo" can mean; keeping a slot is a decision about a
+/// specific capture, and guessing which would be guessing what a run is for.
+fn named_slot(
+ pool: &Pool,
+ backup: Option<&str>,
+ verb: &str,
+) -> Result {
+ let available = pool.list()?;
+ let Some(text) = backup else {
+ return Err(local(if available.is_empty() {
+ format!("{verb} requires --backup , and this target has no backups")
+ } else {
+ format!(
+ "{verb} requires --backup ; this target holds {}",
+ available
+ .iter()
+ .map(|record| record.backup_ref.as_str())
+ .collect::>()
+ .join(", ")
+ )
+ }));
+ };
+ setup_core::backup::BackupRef::parse(text)
+ .map_err(|error| local(format!("{text:?} is not a backup reference: {error}")))
+}
+
/// What the program directory holds, and which version answers to the command.
///
/// Install and update are not here, and their absence is the design rather than
@@ -562,12 +701,16 @@ fn backups(harness: &Harness, target: &Path) -> Result<()> {
}
println!("Backups of {}, newest first:", resolved.root().display());
println!();
+ let pool = Pool::observe(&resolved.control_directory(), facts::BACKUP_SLOTS)?;
for (position, record) in records.iter().enumerate() {
- let marker = if position == 0 {
- " (restored by default)"
- } else {
- ""
- };
+ let mut marker = String::new();
+ if position == 0 {
+ marker.push_str(" (restored by default)");
+ }
+ if let Some(why) = pool.held_reason(&record.backup_ref)? {
+ use std::fmt::Write as _;
+ let _ = write!(marker, " (held: {why})");
+ }
println!(" {}{marker}", record.backup_ref.as_str());
println!(
" before {}, setup {}",
diff --git a/crates/harness-runtime/src/lib.rs b/crates/harness-runtime/src/lib.rs
index de51fb4..b098ee0 100644
--- a/crates/harness-runtime/src/lib.rs
+++ b/crates/harness-runtime/src/lib.rs
@@ -198,6 +198,8 @@ fn print_help(harness: &Harness) {
println!(" diff --target ");
println!(" backups --target ");
println!(" restore [--backup ] --target ");
+ println!(" hold --backup [--reason ] --target ");
+ println!(" release --backup --target ");
println!(" remove --target ");
if !harness.predecessor_state_file.is_empty() {
println!(" adopt --target ");
@@ -231,6 +233,111 @@ fn print_help(harness: &Harness) {
println!("previous, only what is on disk.");
println!();
println!("A backup is captured before every change, so `restore` always has");
- println!("something to return to. Over the wire, install and replace arrive as");
- println!("a bundle and refuse -- this build reads setups from its own catalog.");
+ println!("something to return to. The pool rolls, so a long series of changes");
+ println!("eventually evicts the oldest: `hold` keeps one until `release` lets");
+ println!("it go, which is how a baseline survives more captures than the pool.");
+ println!("Over the wire, install and replace arrive as a bundle and refuse --");
+ println!("this build reads setups from its own catalog.");
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+ /// The entry point uses the toolchain this tree pins, even where a
+ /// different one comes first on `PATH`.
+ ///
+ /// This was closed once by *running* it: with `~/.local/bin` first,
+ /// `cargo test --doc` failed with `E0514` and `scripts/gate.sh` reported
+ /// the pinned version anyway. A run is not a test. The next
+ /// `rust-toolchain.toml` bump is when a silently wrong entry point costs
+ /// something, and until now nothing would have been watching.
+ ///
+ /// The shim is the shadow the trap describes: a real executable named
+ /// `cargo`, earlier on `PATH`, reporting a different release. The test
+ /// proves it *would* have won, and then that the entry point selects past
+ /// it — otherwise a passing assertion could mean the shim was never
+ /// consulted at all.
+ #[test]
+ #[cfg(unix)]
+ fn the_entry_point_selects_the_pinned_toolchain_past_a_shadow() {
+ use std::os::unix::fs::PermissionsExt;
+ use std::process::Command;
+
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+
+ // This crate is vendored into seven published trees, and the entry
+ // point it tests belongs to exactly one repository: the workspace where
+ // the code is written and the render is proved. A published tree ships
+ // no `scripts/` at all, because contributing there means something
+ // else -- so the test travelled somewhere its subject does not exist,
+ // and failed on ubuntu and macos in all seven at once.
+ //
+ // Absence is asserted rather than skipped. A rendered tree is
+ // identifiable: it carries no renderer either. If both are gone this is
+ // a published tree and there is nothing here to test; if the script is
+ // gone and the renderer is not, someone deleted the entry point in the
+ // workspace and that is a failure, not a skip.
+ if !root.join("scripts/gate.sh").is_file() {
+ assert!(
+ !root.join("tools/render_public_trees.py").is_file(),
+ "the toolchain entry point is missing from a workspace that still \
+ renders the public trees; scripts/gate.sh was deleted rather than \
+ never present"
+ );
+ return;
+ }
+
+ let pinned = std::fs::read_to_string(root.join("rust-toolchain.toml"))
+ .unwrap()
+ .lines()
+ .find_map(|line| {
+ line.strip_prefix("channel = \"")
+ .and_then(|rest| rest.strip_suffix('"'))
+ .map(str::to_owned)
+ })
+ .expect("rust-toolchain.toml pins a channel");
+
+ let shadow = std::env::temp_dir().join(format!("gate-shadow-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&shadow);
+ std::fs::create_dir_all(&shadow).unwrap();
+ let shim = shadow.join("cargo");
+ std::fs::write(&shim, "#!/bin/sh\necho 'cargo 1.0.0 (shadow)'\n").unwrap();
+ std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
+
+ let shadowed = format!(
+ "{}:{}",
+ shadow.display(),
+ std::env::var("PATH").unwrap_or_default()
+ );
+
+ // The shim would have won. Without this the assertion below could pass
+ // because nothing ever put it in the way.
+ let shadowing = Command::new("sh")
+ .arg("-c")
+ .arg("cargo --version")
+ .env("PATH", &shadowed)
+ .output()
+ .unwrap();
+ assert!(
+ String::from_utf8_lossy(&shadowing.stdout).contains("1.0.0 (shadow)"),
+ "the shim did not shadow cargo, so this test proves nothing"
+ );
+
+ let asked = Command::new("bash")
+ .arg("scripts/gate.sh")
+ .arg("--toolchain")
+ .current_dir(&root)
+ .env("PATH", &shadowed)
+ .output()
+ .unwrap();
+ let said = String::from_utf8_lossy(&asked.stdout).into_owned();
+ let _ = std::fs::remove_dir_all(&shadow);
+
+ assert!(asked.status.success(), "the entry point failed: {said}");
+ assert!(
+ said.contains(&format!("cargo reports {pinned}")),
+ "the entry point used the shadow rather than the pinned {pinned}: {said}"
+ );
+ }
}
diff --git a/crates/harness-runtime/src/software.rs b/crates/harness-runtime/src/software.rs
index 74fa166..7fd2e66 100644
--- a/crates/harness-runtime/src/software.rs
+++ b/crates/harness-runtime/src/software.rs
@@ -116,14 +116,21 @@ pub(crate) fn plan(
let root = program_directory(prefix, operation)?;
version_for(&declared, software_version)?;
- let entry_point = format!("bin/{}", declared.command);
+ // Not always the command: pi's entry point is JavaScript, and Windows runs
+ // a file by its extension rather than by a shebang, so what is exposed
+ // there is `pi.cmd`. The plan states what a caller will actually be able to
+ // run, which is the whole point of naming it.
+ let entry_point = format!(
+ "bin/{}",
+ setup_core::software::exposed_name(declared.command, declared.member_hint())
+ );
let exposed = root.join(&entry_point);
// Planning may read the local disk and may not reach the network, so what
// is already under the prefix belongs in the plan. Without it an install
// and an update produced byte-identical effects -- two names for one act,
// and neither said what was about to be replaced.
- let present = software::Present::under(&root, declared.command);
+ let present = software::Present::under_named(&root, declared.command, declared.member_hint());
if operation == Operation::SoftwareRemove {
let mut effects = vec![
@@ -285,7 +292,7 @@ pub(crate) fn apply(
// the prefix could have been emptied in between. The plan's digest binds
// what was decided, not what the disk still holds.
if operation == Operation::SoftwareUpdate
- && software::Present::under(&root, declared.command)
+ && software::Present::under_named(&root, declared.command, declared.member_hint())
.versions
.is_empty()
{
@@ -309,7 +316,10 @@ pub(crate) fn apply(
"operation": operation.as_str(),
"command": declared.command,
"version": installed.version,
- "entry_point": format!("bin/{}", declared.command),
+ "entry_point": format!(
+ "bin/{}",
+ setup_core::software::exposed_name(declared.command, declared.member_hint())
+ ),
"executable": installed.executable.to_string_lossy(),
"files": installed.files,
}))
@@ -360,7 +370,13 @@ pub(crate) fn launch(
let declared = declared(harness)?;
let root = program_directory(prefix, Operation::Launch)?;
- let executable = root.join("bin").join(declared.command);
+ // The same name the plan stated and the apply wrote. Three readings of one
+ // fact, and they have to be one expression or they will drift apart on the
+ // one platform where they differ.
+ let executable = root.join("bin").join(setup_core::software::exposed_name(
+ declared.command,
+ declared.member_hint(),
+ ));
let found = executable.metadata().map_err(|error| {
Error::refuse(
diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs
index d21f0f2..ae6121f 100644
--- a/crates/harness-runtime/src/wire.rs
+++ b/crates/harness-runtime/src/wire.rs
@@ -2029,6 +2029,241 @@ mod tests {
assert_eq!(plan_then_apply(&target, "backup", &[])["state"], "verified");
}
+ /// A configuration operation is bound to the target it planned against; a
+ /// software operation is not, and that difference is load-bearing.
+ ///
+ /// `perform` re-checks `expected_target_digest` under the lock and refuses
+ /// `Stale` when the target moved. Software operations do not go through it
+ /// at all, so a configuration edit between plan and apply does **not**
+ /// strand a program install — which is correct: a program lives under
+ /// `--prefix` and has nothing to do with the configuration in the target,
+ /// and binding them would let someone editing their own instructions
+ /// invalidate a download of a hundred and sixty megabytes.
+ ///
+ /// It was correct, deliberate, undocumented and held by nothing. The
+ /// consumer has been told they may rely on it, so it is asserted from both
+ /// sides here: routing software through `perform` "for uniformity" is a
+ /// reasonable-looking cleanup that would break them silently, with every
+ /// existing test still green.
+ #[test]
+ fn a_configuration_edit_strands_a_configuration_plan_and_not_a_program_one() {
+ // The software side: plan, edit the target, apply, and it still lands.
+ let target = seeded("precondition-software");
+ let file = downloaded(&target, TEST_PAYLOAD);
+ let planned = software_plan(&target, "software_install");
+ let plan_path = target.join("..").join("precondition-plan.json");
+ fs::write(
+ &plan_path,
+ setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(),
+ )
+ .unwrap();
+
+ fs::write(
+ target.join("AGENTS.md"),
+ "# edited between plan and apply\n",
+ )
+ .unwrap();
+
+ let prefix = target.join("..").join("precondition-prefix");
+ let applied = run(args(
+ "apply-operation",
+ &target,
+ &[
+ "--plan",
+ &plan_path.to_string_lossy(),
+ "--plan-digest",
+ planned["plan_digest"].as_str().unwrap(),
+ "--provider-release-digest",
+ RELEASE,
+ "--prefix",
+ &prefix.to_string_lossy(),
+ "--software-artifact",
+ &file.to_string_lossy(),
+ ],
+ ));
+ assert_eq!(
+ applied["state"], "verified",
+ "a configuration edit stranded a program install"
+ );
+
+ // The configuration side, same edit, and it must refuse: a plan that
+ // authorized one target state cannot be applied to another.
+ let other = seeded("precondition-configuration");
+ let config_plan = run(args(
+ "plan-operation",
+ &other,
+ &[
+ "--operation",
+ "backup",
+ "--provider-release-digest",
+ RELEASE,
+ "--operation-id",
+ "operation_01PRECOND",
+ "--expires-at",
+ far_future(),
+ ],
+ ));
+ let config_path = other.join("..").join("precondition-config-plan.json");
+ fs::write(
+ &config_path,
+ setup_core::canonical::to_canonical_bytes(&config_plan["plan"]).unwrap(),
+ )
+ .unwrap();
+
+ fs::write(other.join("AGENTS.md"), "# edited between plan and apply\n").unwrap();
+
+ let error = refuse(args(
+ "apply-operation",
+ &other,
+ &[
+ "--plan",
+ &config_path.to_string_lossy(),
+ "--plan-digest",
+ config_plan["plan_digest"].as_str().unwrap(),
+ "--provider-release-digest",
+ RELEASE,
+ ],
+ ));
+ assert_eq!(
+ error.reason(),
+ Some(WireReason::Stale),
+ "a configuration plan survived the target changing under it: {}",
+ error.detail()
+ );
+ }
+
+ /// Holds a file unreadable for as long as it lives, and puts it back.
+ ///
+ /// Two mechanisms, one condition. On Windows a file another process holds
+ /// open with no sharing cannot be opened at all; on Unix the same effect
+ /// comes from permissions. A guard rather than a bare call so the condition
+ /// cannot outlive the test that wanted it.
+ struct Unreadable {
+ #[cfg(windows)]
+ _handle: fs::File,
+ #[cfg(unix)]
+ path: PathBuf,
+ }
+
+ impl Unreadable {
+ /// `None` when this process can still read the file afterwards, which
+ /// is what running as root looks like. A test that cannot create its
+ /// condition must say so rather than pass.
+ fn of(path: &Path) -> Option {
+ #[cfg(windows)]
+ {
+ use std::os::windows::fs::OpenOptionsExt;
+ // Deny every kind of sharing: what a running product does to a
+ // database or a log it owns.
+ let handle = fs::OpenOptions::new()
+ .read(true)
+ .share_mode(0)
+ .open(path)
+ .ok()?;
+ fs::File::open(path)
+ .is_err()
+ .then_some(Self { _handle: handle })
+ }
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(path, fs::Permissions::from_mode(0o000)).ok()?;
+ if fs::File::open(path).is_ok() {
+ let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o644));
+ return None;
+ }
+ Some(Self {
+ path: path.to_path_buf(),
+ })
+ }
+ }
+ }
+
+ impl Drop for Unreadable {
+ fn drop(&mut self) {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = fs::set_permissions(&self.path, fs::Permissions::from_mode(0o644));
+ }
+ }
+ }
+
+ /// A file inside an owned namespace that cannot be read stops the operation
+ /// before it starts, names the file, and leaves nothing behind.
+ ///
+ /// `G12` left this open and `run_once_it_is_not_busy` was never it: that is
+ /// a Linux `ETXTBSY` fork race the test harness creates by writing and
+ /// exec'ing in one multi-threaded process, and its own comment says so.
+ ///
+ /// The real condition is a product holding its own file open — a database,
+ /// a log — inside a namespace this provider owns. It is reachable on both
+ /// systems and it is the same condition: on Windows through a share mode
+ /// that denies everything, on Unix through permissions.
+ ///
+ /// What must be true is not that the operation succeeds. A slot that
+ /// silently skipped a file it could not read would not restore the target,
+ /// which is worse than refusing. What must be true is that the refusal
+ /// **names the file** and that nothing partial survives it: identity is
+ /// computed before any capture, so a target that cannot be read is a target
+ /// nothing has been done to.
+ #[test]
+ fn a_file_this_process_cannot_read_stops_the_operation_and_leaves_nothing() {
+ let target = seeded("unreadable");
+ plan_then_apply(&target, "backup", &[]);
+ let control = target.join(TEST.control_directory);
+ let before = fs::read_dir(control.join("backups")).map_or(0, Iterator::count);
+
+ let locked = target.join("skills").join("held-open.md");
+ fs::write(&locked, "a product owns this").unwrap();
+ let Some(held) = Unreadable::of(&locked) else {
+ // Cannot create the condition here — running as root, or the
+ // platform declined. Saying so beats a green that proves nothing.
+ panic!(
+ "this process can still read a file it made unreadable, so this test \
+ would prove nothing; it needs to run as a user permissions apply to"
+ );
+ };
+
+ let error = refuse(args(
+ "plan-operation",
+ &target,
+ &[
+ "--operation",
+ "backup",
+ "--provider-release-digest",
+ RELEASE,
+ "--operation-id",
+ "operation_01LOCKED",
+ "--expires-at",
+ far_future(),
+ ],
+ ));
+ assert!(
+ error.detail().contains("held-open.md"),
+ "the refusal does not name the file it could not read: {}",
+ error.detail()
+ );
+
+ // Nothing started. No new slot, no journal, no transaction to recover.
+ assert_eq!(
+ fs::read_dir(control.join("backups")).map_or(0, Iterator::count),
+ before,
+ "a refused operation left a backup slot behind"
+ );
+ assert!(!control.join("journal.json").exists());
+ assert!(!control.join("transaction").exists());
+
+ // And it recovers by itself once the file is readable again: nothing
+ // was recorded that has to be undone.
+ drop(held);
+ assert_eq!(
+ plan_then_apply(&target, "backup", &[])["state"],
+ "verified",
+ "the target did not become usable again once the file could be read"
+ );
+ }
+
#[test]
fn two_status_calls_return_the_same_bytes() {
// The consumer calls it twice and requires the answers to be identical,
diff --git a/crates/setup-core/src/backup.rs b/crates/setup-core/src/backup.rs
index a3ebe79..48c47d9 100644
--- a/crates/setup-core/src/backup.rs
+++ b/crates/setup-core/src/backup.rs
@@ -30,6 +30,20 @@ pub const SLOT_MARKER_NAME: &str = "slot.json";
/// The subdirectory holding the captured tree.
pub const SLOT_PAYLOAD_NAME: &str = "payload";
+/// The marker naming a slot that retention may not reclaim.
+///
+/// Beside the record rather than inside it, on purpose. The record states what
+/// was *captured*; a hold states whether retention may take it back. Those are
+/// two different facts about one slot, and writing the second into the first
+/// would mean editing a completed capture every time someone changed their mind
+/// about keeping it.
+///
+/// The file holds the reason the hold was placed. A pool can be held by more
+/// than one run at a time, and a refusal that says only *which* slots are held
+/// leaves the next caller releasing one blind — possibly one another run is
+/// still depending on.
+pub const SLOT_HELD_NAME: &str = "HELD";
+
/// The schema this kernel writes and is willing to read.
pub const SLOT_SCHEMA: u32 = 1;
@@ -329,6 +343,148 @@ impl Pool {
Ok(record)
}
+ /// Keep one slot until it is explicitly released.
+ ///
+ /// The pool rolls: ten slots, oldest evicted. A long evidence series makes
+ /// more captures than that, so the baseline it means to return to at the
+ /// end is gone by the time it gets there — reported after a run of fifty
+ /// captures could not restore the state it started from.
+ ///
+ /// A hold is the smallest thing that fixes it: one marker that eviction has
+ /// to read. Against an export/import format, which is a second format to
+ /// keep correct, and against a separate baseline store, which is a second
+ /// place state lives. With a hold, *retention never silently changes what a
+ /// `BackupRef` names* becomes a checkable statement rather than an
+ /// intention.
+ ///
+ /// # 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.
+ 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() {
+ return Err(Error::new(
+ ReasonCode::InvalidTarget,
+ format!(
+ "{} is not a completed slot in this pool",
+ backup_ref.as_str()
+ ),
+ ));
+ }
+ let already = self.held()?;
+ 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| {
+ Error::new(
+ ReasonCode::StateUnavailable,
+ format!("cannot hold {}", slot.display()),
+ )
+ .with_source(source)
+ })?;
+ Ok(true)
+ }
+
+ /// Let retention have a slot back.
+ ///
+ /// Answers `false` for a slot that was not held rather than refusing: a run
+ /// cleaning up after itself should not have to tell "nothing to do" apart
+ /// from "something is wrong", which is the same reason `remove` answers
+ /// `removed: false` on a prefix it never wrote.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReasonCode::StateUnavailable`] if the marker cannot be removed.
+ pub fn release(&self, backup_ref: &BackupRef) -> Result {
+ let marker = self.root.join(backup_ref.as_str()).join(SLOT_HELD_NAME);
+ match fs::remove_file(&marker) {
+ Ok(()) => Ok(true),
+ Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
+ Err(source) => Err(Error::new(
+ ReasonCode::StateUnavailable,
+ format!("cannot release {}", marker.display()),
+ )
+ .with_source(source)),
+ }
+ }
+
+ /// Every slot retention may not reclaim, newest first, with why.
+ ///
+ /// # Errors
+ ///
+ /// Propagates the refusal from [`Pool::list`].
+ pub fn held(&self) -> Result> {
+ let mut out = Vec::new();
+ for record in self.list()? {
+ let marker = self
+ .root
+ .join(record.backup_ref.as_str())
+ .join(SLOT_HELD_NAME);
+ if let Ok(reason) = fs::read_to_string(&marker) {
+ let reason = reason.trim().to_owned();
+ let reason = if reason.is_empty() {
+ // A hold placed before reasons were carried, or one whose
+ // caller gave none. Saying so beats an empty parenthesis.
+ "no reason recorded".to_owned()
+ } else {
+ reason
+ };
+ out.push((record.backup_ref, reason));
+ }
+ }
+ Ok(out)
+ }
+
+ /// Why one slot is held, when it is.
+ ///
+ /// # Errors
+ ///
+ /// Propagates the refusal from [`Pool::list`].
+ pub fn held_reason(&self, backup_ref: &BackupRef) -> Result