From 23fb1c25b443601ff469a9359619e42f8f478535 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Wed, 2 Sep 2026 18:08:11 +0500 Subject: [PATCH] feat: a removal needs a record of its own Rendered from NDDev-it-com/setup-systems by tools/render_public_trees.py at 0.0.58. This tree is generated: fix the source and the renderer, never this checkout. --- CHANGELOG.md | 28 ++++ Cargo.lock | 8 +- Cargo.toml | 8 +- README.md | 2 +- crates/harness-runtime/src/human.rs | 38 +++++ crates/harness-runtime/src/wire.rs | 220 ++++++++++++++++++++++++++++ install.ps1 | 2 +- install.sh | 2 +- references/opencode-baseline.json | 14 +- 9 files changed, 310 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7909e52..15e1b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,34 @@ sibling project the same week this note was added. ## [Unreleased] +## [0.0.58] - 2026-09-02 + +A removal answers three ways when nothing records what this build +wrote. `remove` takes the declared namespaces whole, which is exact for a +target this provider wrote and is guessing at one it never touched: measured on +released 0.0.57, a target holding only a person's own configuration answered +*"Removed everything owns"* and took it, recoverable from the +capture and under a sentence that did not describe what happened. + +Now: a record removes, as before. A target with nothing this provider declares +on it is silent and unchanged, because "already removed" must stay a no-op or +a repeat becomes an error where nothing happened. A target with declared +entries and no record is refused by name -- `state: refused`, +`unsupported_operation`, exit 0 -- with the entries it would have taken in the +detail, so a consumer can show a person what it declined to take. The same +three answers on the surface a person types and on the wire, and the question +is asked again under the lock, because the state file is outside the target's +identity on purpose and a record can be deleted between a plan and its apply. + +The shape was agreed with the consumer before it shipped, and their half went +first: their reader now carries a refusal's reason and detail through to a +person rather than collapsing every non-planned answer into one sentence about +shape. + +Every declared surface of all seven products is re-measured in the bytes each +current pin names, with an invented control absent in all of them: 72 of 72 +present. + ## [0.0.57] - 2026-09-02 A kind declared only by a scoped profile validates and plans under diff --git a/Cargo.lock b/Cargo.lock index d3eeb73..aca44ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "harness-runtime" -version = "0.0.57" +version = "0.0.58" dependencies = [ "provider-v3", "serde", @@ -128,7 +128,7 @@ dependencies = [ [[package]] name = "opencode-setup-system" -version = "0.0.57" +version = "0.0.58" dependencies = [ "harness-runtime", "provider-v3", @@ -147,7 +147,7 @@ dependencies = [ [[package]] name = "provider-v3" -version = "0.0.57" +version = "0.0.58" dependencies = [ "serde", "serde_json", @@ -209,7 +209,7 @@ dependencies = [ [[package]] name = "setup-core" -version = "0.0.57" +version = "0.0.58" dependencies = [ "miniz_oxide", "serde", diff --git a/Cargo.toml b/Cargo.toml index 123400d..5771d5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.0.57" +version = "0.0.58" 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.57" } -provider-v3 = { path = "crates/provider-v3", version = "0.0.57" } -harness-runtime = { path = "crates/harness-runtime", version = "0.0.57" } +setup-core = { path = "crates/setup-core", version = "0.0.58" } +provider-v3 = { path = "crates/provider-v3", version = "0.0.58" } +harness-runtime = { path = "crates/harness-runtime", version = "0.0.58" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 6218ef4..7646e38 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ release is a convenience, not the authorised copy. ```bash docker run --rm -v "$HOME/.config:/config" \ - ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.57 \ + ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.58 \ status --target /config/ --json ``` diff --git a/crates/harness-runtime/src/human.rs b/crates/harness-runtime/src/human.rs index 2487d95..2afeafa 100644 --- a/crates/harness-runtime/src/human.rs +++ b/crates/harness-runtime/src/human.rs @@ -923,6 +923,44 @@ fn restore(harness: &Harness, target: &Path, backup: Option) -> Result<( } fn remove(harness: &Harness, target: &Path) -> Result<()> { + // **A removal with no record of its own is not this provider's removal.** + // `remove_managed` walks the declared namespaces and takes each whole, which + // is exactly right for a target this build wrote: the setup owns those + // entries and the state file says so. On a target it never wrote, the same + // walk empties somebody else's home while the report says "removed + // everything owns" -- and it owned nothing here. Measured + // 2026-09-02 on a target holding only a person's own `config.toml`, + // `AGENTS.md` and `prompts/`: all three went, recoverable from the slot the + // capture took, under a sentence that did not describe what happened. + // + // The scoped branch of `remove_managed` already refuses for the same + // reason, in the same words -- *this build does not know what it wrote* -- + // and this is that refusal on the surface a person types. The wire is not + // changed: a consumer's removal is authorized by a plan it made against a + // target it installed, and its own flow writes state first. + // Three answers, and the middle one is why this is not a flat refusal: a + // target with nothing of ours on it is *already removed*, and saying so + // quietly keeps a repeat from becoming an error where nothing happened. + // The consumer chose this shape on 2026-09-02 and takes the same three + // over the wire. + let resolved = Target::resolve(target, harness.control_directory)?; + match wire::classify_removal(harness, &resolved, HUMAN_SCOPE)? { + wire::Removal::Recorded => {} + wire::Removal::NothingHere => { + println!( + "Nothing to remove: {} has applied no setup at {}, and none of \ + what it declares is here.", + harness.provider_id, + resolved.root().display() + ); + return Ok(()); + } + wire::Removal::WouldTakeUnrecorded(present) => { + return Err(wire::unrecorded_removal_refusal( + harness, &resolved, &present, + )); + } + } let report = mutate( harness, target, diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index 68468a2..85ae571 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -838,6 +838,11 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result { + if let Removal::WouldTakeUnrecorded(present) = + classify_removal(harness, &resolved, request.target_scope)? + { + return Err(unrecorded_removal_refusal(harness, &resolved, &present)); + } let (lines, states) = removal_effects(harness, &resolved, request)?; end_state = states; (lines, None, None) @@ -874,6 +879,105 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result), +} + +pub(crate) fn classify_removal( + harness: &Harness, + target: &Target, + scope: Option, +) -> Result { + // Under a scope the inventory *is* the record, and `remove_managed` + // refuses there by name already. This function is about the global + // profile, where the namespaces are what a removal takes. + if harness.scoped_for(scope).is_some() { + return Ok(Removal::Recorded); + } + if matches!( + ProviderState::read(target.root(), harness.state_file)?, + StateReading::Current(_) + ) { + return Ok(Removal::Recorded); + } + let present: Vec = harness + .native_namespaces + .iter() + .filter(|namespace| target.root().join(namespace).symlink_metadata().is_ok()) + .map(|namespace| (*namespace).to_owned()) + .collect(); + if present.is_empty() { + return Ok(Removal::NothingHere); + } + Ok(Removal::WouldTakeUnrecorded(present)) +} + +/// The same question under the lock, because a record can vanish between a +/// plan and its apply. +/// +/// The state file is deliberately outside the target's identity -- counting it +/// would make an applied operation leave the target different from the identity +/// it just recorded -- so deleting it does not move the digest that authorized +/// the plan, and a removal authorized while managed could arrive unrecorded. +fn refuse_an_unrecorded_removal( + harness: &Harness, + resolved: &Target, + mutation: &Mutation<'_>, +) -> Result<()> { + if !matches!( + mutation.effect, + Effect::Remove | Effect::RemoveKeeping { .. } + ) { + return Ok(()); + } + if let Removal::WouldTakeUnrecorded(present) = + classify_removal(harness, resolved, mutation.target_scope)? + { + return Err(unrecorded_removal_refusal(harness, resolved, &present)); + } + Ok(()) +} + +/// The refusal `classify_removal` calls for, in the words both surfaces use. +pub(crate) fn unrecorded_removal_refusal( + harness: &Harness, + target: &Target, + present: &[String], +) -> Error { + Error::refuse( + WireReason::UnsupportedOperation, + format!( + "{} has applied no setup at {} -- no state file, or one written before \ + this build recorded what it wrote. Removing would take {} whole, and \ + nothing here says this provider put them there. Install a setup first \ + if you want one removed, or take what you put there yourself.", + harness.provider_id, + target.root().display(), + present.join(", ") + ), + ) +} + /// A target managed under a scope is planned under that scope, or refused by /// name. /// @@ -1426,6 +1530,7 @@ pub(crate) fn perform( // four Junctions. refuse_a_neighbours_home(harness, &resolved, mutation.target_scope)?; refuse_uncapturable(&resolved, &owned)?; + refuse_an_unrecorded_removal(harness, &resolved, mutation)?; let captured = pool.capture(resolved.root(), &as_paths(&owned), |backup_ref| { SlotRecord { schema_version: SLOT_SCHEMA, @@ -2655,6 +2760,7 @@ mod tests { #[test] fn a_plan_names_the_namespaces_it_takes_whole_before_the_writes() { let target = seeded("effects-name-what-goes"); + assert_eq!(plan_then_apply(&target, "backup", &[])["state"], "verified"); let planned = run(args( "plan-operation", &target, @@ -3223,6 +3329,7 @@ mod tests { fn a_removal_without_a_scope_still_withdraws_the_namespaces() { let target = seeded("unscoped-remove"); assert!(target.join("AGENTS.md").exists()); + assert_eq!(plan_then_apply(&target, "backup", &[])["state"], "verified"); let done = plan_then_apply(&target, "remove", &[]); assert_eq!(done["state"], "verified", "{done}"); assert!( @@ -4437,6 +4544,10 @@ mod tests { #[test] fn remove_withdraws_only_what_this_provider_owns() { let target = seeded("remove"); + // A record is what a real removal has: the consumer installs, or any + // operation that writes state runs, before it removes. Without one the + // removal is refused by name -- its own test is below. + assert_eq!(plan_then_apply(&target, "backup", &[])["state"], "verified"); assert_eq!(plan_then_apply(&target, "remove", &[])["state"], "verified"); assert!(!target.join("AGENTS.md").exists()); assert!(!target.join("settings.json").exists()); @@ -5084,6 +5195,7 @@ mod tests { #[test] fn a_remove_may_carry_the_bytes_a_path_keeps_and_leaves_them_behind() { let target = seeded("remove-keeping"); + assert_eq!(plan_then_apply(&target, "backup", &[])["state"], "verified"); let survivor = "{\"model\":\"mine, not the setup's\"}\n"; let (planned, apply_args) = remove_keeping_plan( &target, @@ -5157,6 +5269,7 @@ mod tests { #[test] fn a_remove_without_a_bundle_carries_no_end_state_member() { let target = seeded("remove-bare-plan"); + assert_eq!(plan_then_apply(&target, "backup", &[])["state"], "verified"); let planned = run(args( "plan-operation", &target, @@ -5185,6 +5298,7 @@ mod tests { #[test] fn a_remove_apply_takes_exactly_the_bundle_its_plan_described() { let target = seeded("remove-authorization"); + assert_eq!(plan_then_apply(&target, "backup", &[])["state"], "verified"); let (bytes, bundle_digest, artifact) = bundle_bytes(&[("settings.json", "{\"kept\":true}\n", 0o644)]); let artifact_path = target.join("..").join("unplanned.zip"); @@ -5601,6 +5715,112 @@ mod tests { ); } + /// The three answers a removal has when this build has no record of + /// writing here, agreed with the consumer on 2026-09-02: a record removes, + /// an empty target is silent, a populated one is refused by name. The + /// middle answer is the one that keeps a repeat from becoming an error. + #[test] + fn a_removal_answers_three_ways_when_no_record_says_what_this_build_wrote() { + // Nothing of ours on disk: planned, and the apply is a no-op that + // still leaves a record behind. + let empty = scratch("remove-nothing-here").join("target"); + fs::write(empty.join("unrelated.txt"), "theirs").unwrap(); + let done = plan_then_apply(&empty, "remove", &[]); + assert_eq!(done["state"], "verified", "{done}"); + assert_eq!( + fs::read_to_string(empty.join("unrelated.txt")).unwrap(), + "theirs" + ); + + // Declared entries here and no record: an answered refusal naming them, + // and nothing touched. + let populated = seeded("remove-unrecorded"); + let error = refuse(args( + "plan-operation", + &populated, + &[ + "--operation", + "remove", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01UNRECORDED", + "--expires-at", + far_future(), + ], + )); + assert_eq!(error.reason(), Some(WireReason::UnsupportedOperation)); + assert!( + error.detail().contains("has applied no setup at") + && error.detail().contains("AGENTS.md"), + "{}", + error.detail() + ); + for kept in ["AGENTS.md", "settings.json", "unrelated.txt"] { + assert!(populated.join(kept).exists(), "the refusal took {kept}"); + } + + // A record, however it was written: the removal is the one it was. + assert_eq!( + plan_then_apply(&populated, "backup", &[])["state"], + "verified" + ); + assert_eq!( + plan_then_apply(&populated, "remove", &[])["state"], + "verified" + ); + assert!(!populated.join("AGENTS.md").exists()); + } + + /// A person's own files are not this provider's to withdraw from a target + /// it never wrote to. Measured on the released 0.0.57 human surface: a + /// target holding only a person's `AGENTS.md`, `settings.json` and + /// `skills/` answered *"Removed everything owns"* and took all + /// three, recoverable from the slot and under a sentence that did not + /// describe what happened. The scoped branch of `remove_managed` already + /// refuses on the same ground -- this build does not know what it wrote -- + /// and this is that refusal where a person types it. + #[test] + fn a_human_removal_with_no_record_of_its_own_is_refused() { + let target = seeded("human-remove-unmanaged"); + assert!(target.join(TEST.state_file).symlink_metadata().is_err()); + + let error = crate::human::run( + &TEST, + crate::human::Command::Remove { + target: target.clone(), + }, + ) + .unwrap_err(); + assert_eq!(error.reason(), Some(WireReason::UnsupportedOperation)); + assert!( + error.detail().contains("has applied no setup at"), + "{}", + error.detail() + ); + for kept in ["AGENTS.md", "settings.json", "unrelated.txt"] { + assert!(target.join(kept).exists(), "the refusal took {kept}"); + } + assert!(target.join("skills").is_dir(), "the refusal took skills/"); + + // With a record -- any operation that writes one -- the removal is the + // one it always was. + assert_eq!(plan_then_apply(&target, "backup", &[])["state"], "verified"); + crate::human::run( + &TEST, + crate::human::Command::Remove { + target: target.clone(), + }, + ) + .unwrap(); + assert!(!target.join("AGENTS.md").exists()); + assert!(!target.join("skills").exists()); + assert_eq!( + fs::read_to_string(target.join("unrelated.txt")).unwrap(), + "keep me" + ); + } + /// A harness shaped like cursor's project scope, for the workspace cases. fn project_shaped() -> Harness { let mut harness = TEST; diff --git a/install.ps1 b/install.ps1 index c895837..356e487 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.57", + [string]$Version = "0.0.58", [string]$InstallDir = "$env:LOCALAPPDATA\Programs\opencode-setup-system" ) $ErrorActionPreference = "Stop" diff --git a/install.sh b/install.sh index 3d59aa7..4ebeb06 100644 --- a/install.sh +++ b/install.sh @@ -14,7 +14,7 @@ set -eu REPO="NDDev-OpenNetwork/opencode-setup-system" BINARY="opencode-setup-system" -VERSION="${1:-0.0.57}" +VERSION="${1:-0.0.58}" PREFIX="${OPENCODE_INSTALL_DIR:-$HOME/.local/bin}" case "$(uname -s)" in diff --git a/references/opencode-baseline.json b/references/opencode-baseline.json index aadffa2..401cbfd 100644 --- a/references/opencode-baseline.json +++ b/references/opencode-baseline.json @@ -378,5 +378,17 @@ "version": "1.18.25", "verified_at": "2026-08-29T10:56:16+00:00" }, - "source_verified_runtime_flags_note": "All five read out of the 1.18.25 binary on 2026-08-31 -- the whole `OPENCODE_*` set is in its string table, and these are the five this provider has a reason to name. **Nothing in this repository read this block until now.** It is the same shape as the `windows` row that sat under `unsupported` for weeks while this provider installed Windows: a true-when-written list with no reader, which is the condition a stale fact needs. `native_declaration_names_the_switch_it_sets` now ties `updates_off_env` to this list, so the declaration and the measurement cannot drift apart in silence." + "source_verified_runtime_flags_note": "All five read out of the 1.18.25 binary on 2026-08-31 -- the whole `OPENCODE_*` set is in its string table, and these are the five this provider has a reason to name. **Nothing in this repository read this block until now.** It is the same shape as the `windows` row that sat under `unsupported` for weeks while this provider installed Windows: a true-when-written list with no reader, which is the condition a stale fact needs. `native_declaration_names_the_switch_it_sets` now ties `updates_off_env` to this list, so the declaration and the measurement cannot drift apart in silence.", + "surface_presence": { + "measured_at": "2026-09-02", + "artifact": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.26.tgz", + "artifact_sha256": "sha256:990d8b07111517a78ba779709ff8f438e0dcf2a7fb66d36df7507c8e93358f02", + "product_version": "1.18.26", + "declared_surfaces": 8, + "present": 8, + "absent": [], + "control": "zzq-invented-surface-nobody-ships", + "control_hits": 0, + "method": "Every namespace this harness declares, global and scoped, searched as a fixed string in the artifact the current pin names, digest verified before reading; both the declared path and its last segment counted, because a product that composes a path by joining segments never spells the whole of it. A count here is a lower bound and not a routing proof -- what it answers is whether a surface vanished from the product under a version bump, which five of the seven took on 2026-09-02. The invented control was searched in the same run and found nowhere, so the search discriminates." + } }