From b3808bb623b178874109750d0b348f34868cc620 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 8 Sep 2026 14:03:57 -0400 Subject: [PATCH] Prepare v0.1.2 release --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- assets/contract/catalog.json | 2 +- docs/release-acceptance.md | 12 +++- docs/releases/v0.1.2.md | 61 +++++++++++++++++++ release-gates.json | 2 +- scripts/release/check_stored_state.py | 20 +++--- src/configuration/trust.rs | 16 ++--- src/mutation/state.rs | 38 ++++++------ tests/cli_contract.rs | 2 +- .../stored-state/v0.1.1/manifest.json | 1 + .../fixtures/stored-state/v0.1.1/preview.json | 1 + .../fixtures/stored-state/v0.1.1/receipt.json | 1 + .../stored-state/v0.1.1/recovery.json | 1 + tests/fixtures/stored-state/v0.1.1/trust.json | 1 + tests/release_scripts.py | 17 ++++++ tests/skill_install.rs | 2 +- 18 files changed, 142 insertions(+), 41 deletions(-) create mode 100644 docs/releases/v0.1.2.md create mode 100644 tests/fixtures/stored-state/v0.1.1/manifest.json create mode 100644 tests/fixtures/stored-state/v0.1.1/preview.json create mode 100644 tests/fixtures/stored-state/v0.1.1/receipt.json create mode 100644 tests/fixtures/stored-state/v0.1.1/recovery.json create mode 100644 tests/fixtures/stored-state/v0.1.1/trust.json diff --git a/Cargo.lock b/Cargo.lock index d9212d5..87cbb00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -646,7 +646,7 @@ dependencies = [ [[package]] name = "lspctl" -version = "0.1.1" +version = "0.1.2" dependencies = [ "async-lsp", "atomic-write-file", diff --git a/Cargo.toml b/Cargo.toml index 6eb77da..2563076 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lspctl" -version = "0.1.1" +version = "0.1.2" edition = "2024" rust-version = "1.89" default-run = "lspctl" diff --git a/README.md b/README.md index ac10d86..372816e 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ cargo clippy --locked --all-targets --features fake-server -- -D warnings cargo test --locked --all-targets --features fake-server ``` -See the [v0.1.1 release notes](docs/releases/v0.1.1.md) for the tested platform +See the [v0.1.2 release notes](docs/releases/v0.1.2.md) for the tested platform floors, reference-server versions, and artifact provenance. ## License diff --git a/assets/contract/catalog.json b/assets/contract/catalog.json index 9ddd3a8..b822555 100644 --- a/assets/contract/catalog.json +++ b/assets/contract/catalog.json @@ -1,5 +1,5 @@ { - "binaryVersion": "0.1.1", + "binaryVersion": "0.1.2", "contractVersion": 1, "configVersion": 1, "capabilityProfileVersion": 1, diff --git a/docs/release-acceptance.md b/docs/release-acceptance.md index 29aa768..1beab06 100644 --- a/docs/release-acceptance.md +++ b/docs/release-acceptance.md @@ -45,9 +45,15 @@ memory and handles, zero queue depth after quiescence, Document/diagnostic LRU churn, and graceful stop within five seconds. `tests/fixtures/stored-state/v1` is the immutable first-release compatibility -seed. Every future stable release must add fixtures and candidate migration -coverage for all earlier releases in the same major version. The JSON digest -checker and Rust deserialization tests prevent accidental format drift. +seed. `tests/fixtures/stored-state/v0.1.1` explicitly covers v0.1.1 with the +same payloads: the stored types did not change, and both deserialization tests +were run against the immutable v0.1.1 tag before copying its fixtures. The +candidate checks both seeds and exercises legacy recursive-Preview rejection +and legacy Recovery ownership restrictions in the Mutation tests. + +Every future stable release must add fixtures and candidate migration coverage +for all earlier releases in the same major version. The JSON digest checker +and Rust deserialization tests prevent accidental format drift. `release-gates.json` is the authoritative machine-readable list. Candidate workflows fail if any gate is pending; gates are never represented by empty or diff --git a/docs/releases/v0.1.2.md b/docs/releases/v0.1.2.md new file mode 100644 index 0000000..33b0910 --- /dev/null +++ b/docs/releases/v0.1.2.md @@ -0,0 +1,61 @@ +# lspctl 0.1.2 + +This patch release hardens Mutation safety, Owner reliability, and diagnostic +persistence, and adds checksum-verified binary installers. + +## Changes + +- Added shell and PowerShell installers for prebuilt releases. +- Confined companion-skill installation Recovery to validated managed paths and + preserved unrecognized content. +- Bound recursive directory membership to Previews so changed descendants make + a Preview stale, and preserved external filesystem changes during rollback. +- Released Preview reservations after preparation failures when no durable + transaction owns them, allowing the same Preview to be retried safely. +- Corrected ordered Preview rendering for chained text edits and resource + operations, using the same text evaluation as Application staging. +- Preserved partial-result chunks across the Owner boundary and persisted + related diagnostic reports across CLI invocations. +- Prevented Owner maintenance starvation under notification traffic, bounded + unauthenticated admission and handshakes, and bounded language-server writes + while retiring failed transports. +- Preserved Document synchronization after stale requests and failed reads. +- Enforced explicit Workspace and server selection for raw requests that + declare Documents outside the Workspace. +- Kept Trust revocation and listing independent of executable resolution. +- Corrected recursive file-operation glob boundaries, including zero-directory + matches for `**/`. + +## Compatibility + +- Targets: x86_64/aarch64 glibc Linux, x86_64/Apple Silicon macOS, and x86_64 Windows MSVC. +- Runtime floors: glibc 2.28, macOS 12, Windows 10 1809 or Windows Server 2019. +- Rust: MSRV 1.89; locked release build uses the Rust version recorded in each archive manifest. +- Machine schema: v1; configuration: v1; capability profile: v1; Owner protocol: v1. +- Stored state: Trust, Preview, Receipt, and Recovery format v1 remains readable. + +## Upgrade notes + +- Older recursive-directory Previews without directory-membership evidence are + rejected as stale where that evidence is required. Request a fresh Preview; + existing authorization is never silently broadened. +- Recovery preserves external changes. Older journals without transaction + ownership evidence can roll back only when the complete before-manifest + already matches; otherwise inspect Recovery status and resolve the files + manually before accepting the current state. +- Raw requests using `--document` outside the Workspace require explicit + `--workspace` and `--server`. In-Workspace Documents still support inferred + server selection. + +## Reference servers + +- rust-analyzer 2026-08-31 (native runtime) +- typescript-language-server 6.0.0 with TypeScript 6.0.3 and Node 24.6.0 +- basedpyright 1.39.10 with Python 3.13.7 + +## Security and provenance + +SHA-256 sidecars accompany every archive. GitHub build-provenance attestations bind each archive and the companion skill to this immutable tag and source commit. Apple notarization and Windows Authenticode signing are not included. + +No stored-state format migration is required from v0.1.0 or v0.1.1; the stricter +safety checks described above still apply. diff --git a/release-gates.json b/release-gates.json index 91a88e2..7ee5362 100644 --- a/release-gates.json +++ b/release-gates.json @@ -40,7 +40,7 @@ { "name": "stored-state-compatibility", "state": "implemented", - "reason": "The immutable 0.1.0 first-release seed covers Trust, Denial, Preview, Receipt, and Recovery records; digest and Rust deserialization checks run in CI." + "reason": "Immutable 0.1.0 and 0.1.1 seeds cover Trust, Denial, Preview, Receipt, and Recovery records; digest, Rust deserialization, and legacy Preview/Recovery safety checks run in CI." }, { "name": "performance-and-soak", diff --git a/scripts/release/check_stored_state.py b/scripts/release/check_stored_state.py index c972aba..e9832d4 100755 --- a/scripts/release/check_stored_state.py +++ b/scripts/release/check_stored_state.py @@ -8,25 +8,26 @@ from pathlib import Path, PurePath -ROOT = Path(__file__).resolve().parents[2] / "tests/fixtures/stored-state/v1" +ROOT = Path(__file__).resolve().parents[2] / "tests/fixtures/stored-state" REQUIRED = {"trust.json", "preview.json", "receipt.json", "recovery.json"} -def main() -> None: - manifest = json.loads((ROOT / "manifest.json").read_text(encoding="utf-8")) +def check_seed(directory: str, release: str, first_release: bool) -> None: + root = ROOT / directory + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) if set(manifest) != {"formatVersion", "release", "firstRelease", "files"}: raise SystemExit("stored-state manifest has unexpected fields") - if manifest["formatVersion"] != 1 or manifest["release"] != "0.1.0": + if manifest["formatVersion"] != 1 or manifest["release"] != release: raise SystemExit("stored-state manifest identity is invalid") - if manifest["firstRelease"] is not True or not isinstance(manifest["files"], list): - raise SystemExit("stored-state manifest is not a first-release seed") + if manifest["firstRelease"] is not first_release or not isinstance(manifest["files"], list): + raise SystemExit("stored-state manifest release metadata is invalid") names: set[str] = set() for record in manifest["files"]: if set(record) != {"path", "sha256"} or not isinstance(record["path"], str): raise SystemExit("stored-state file entry is invalid") relative = PurePath(record["path"]) - path = ROOT / record["path"] + path = root / record["path"] if ( relative.is_absolute() or len(relative.parts) != 1 @@ -46,5 +47,10 @@ def main() -> None: raise SystemExit(f"stored-state seed is incomplete: {names!r}") +def main() -> None: + check_seed("v1", "0.1.0", True) + check_seed("v0.1.1", "0.1.1", False) + + if __name__ == "__main__": main() diff --git a/src/configuration/trust.rs b/src/configuration/trust.rs index 8ccc1e2..dd14169 100644 --- a/src/configuration/trust.rs +++ b/src/configuration/trust.rs @@ -851,13 +851,15 @@ mod tests { use super::*; #[test] - fn first_release_trust_state_fixture_remains_readable() { - let state: TrustStateFile = serde_json::from_str(include_str!( - "../../tests/fixtures/stored-state/v1/trust.json" - )) - .unwrap(); - assert_eq!(state.format_version, TRUST_STATE_FORMAT_VERSION); - assert_eq!(state.records.len(), 2); + fn same_major_trust_state_fixtures_remain_readable() { + for fixture in [ + include_str!("../../tests/fixtures/stored-state/v1/trust.json"), + include_str!("../../tests/fixtures/stored-state/v0.1.1/trust.json"), + ] { + let state: TrustStateFile = serde_json::from_str(fixture).unwrap(); + assert_eq!(state.format_version, TRUST_STATE_FORMAT_VERSION); + assert_eq!(state.records.len(), 2); + } } #[test] diff --git a/src/mutation/state.rs b/src/mutation/state.rs index 4437070..d139b37 100644 --- a/src/mutation/state.rs +++ b/src/mutation/state.rs @@ -1145,23 +1145,27 @@ mod tests { } #[test] - fn first_release_stored_state_fixtures_remain_readable() { - let preview: StoredPreview = serde_json::from_str(include_str!( - "../../tests/fixtures/stored-state/v1/preview.json" - )) - .unwrap(); - let receipt: StoredReceipt = serde_json::from_str(include_str!( - "../../tests/fixtures/stored-state/v1/receipt.json" - )) - .unwrap(); - let recovery: TransactionRecord = serde_json::from_str(include_str!( - "../../tests/fixtures/stored-state/v1/recovery.json" - )) - .unwrap(); - - assert_eq!(preview.format_version, MUTATION_STATE_VERSION); - assert_eq!(receipt.format_version, MUTATION_STATE_VERSION); - assert_eq!(recovery.format_version, MUTATION_STATE_VERSION); + fn same_major_stored_state_fixtures_remain_readable() { + for (preview, receipt, recovery) in [ + ( + include_str!("../../tests/fixtures/stored-state/v1/preview.json"), + include_str!("../../tests/fixtures/stored-state/v1/receipt.json"), + include_str!("../../tests/fixtures/stored-state/v1/recovery.json"), + ), + ( + include_str!("../../tests/fixtures/stored-state/v0.1.1/preview.json"), + include_str!("../../tests/fixtures/stored-state/v0.1.1/receipt.json"), + include_str!("../../tests/fixtures/stored-state/v0.1.1/recovery.json"), + ), + ] { + let preview: StoredPreview = serde_json::from_str(preview).unwrap(); + let receipt: StoredReceipt = serde_json::from_str(receipt).unwrap(); + let recovery: TransactionRecord = serde_json::from_str(recovery).unwrap(); + + assert_eq!(preview.format_version, MUTATION_STATE_VERSION); + assert_eq!(receipt.format_version, MUTATION_STATE_VERSION); + assert_eq!(recovery.format_version, MUTATION_STATE_VERSION); + } } #[test] diff --git a/tests/cli_contract.rs b/tests/cli_contract.rs index 6ed361a..a63819f 100644 --- a/tests/cli_contract.rs +++ b/tests/cli_contract.rs @@ -17,7 +17,7 @@ fn version_command_and_alias_emit_the_same_machine_envelope() { assert_eq!(envelope["ok"], true); assert_eq!(envelope["command"], json!(["version"])); assert_eq!(envelope["result"]["name"], "lspctl"); - assert_eq!(envelope["result"]["version"], "0.1.1"); + assert_eq!(envelope["result"]["version"], "0.1.2"); assert_eq!(envelope["result"]["contractVersion"], 1); assert_eq!(envelope["result"]["configVersion"], 1); assert_eq!(envelope["result"]["capabilityProfileVersion"], 1); diff --git a/tests/fixtures/stored-state/v0.1.1/manifest.json b/tests/fixtures/stored-state/v0.1.1/manifest.json new file mode 100644 index 0000000..ef64e11 --- /dev/null +++ b/tests/fixtures/stored-state/v0.1.1/manifest.json @@ -0,0 +1 @@ +{"files":[{"path":"preview.json","sha256":"sha256:88ce45d40886f3e4caf6515861f5c167e812778a87ebe870b0ccca072e3b5b03"},{"path":"receipt.json","sha256":"sha256:3192ba8b326d9c9491884162a667422caf52afab025cef6cb500ea903fa44a04"},{"path":"recovery.json","sha256":"sha256:0dec5fdbc3c36220c3ec255beeda5674f7e23b34397dd7dbf6bef0c60fcf00f2"},{"path":"trust.json","sha256":"sha256:34061e1620d8a570fa6794206b2763603e28d104d65d0fbfd7f61bd3eb819787"}],"firstRelease":false,"formatVersion":1,"release":"0.1.1"} diff --git a/tests/fixtures/stored-state/v0.1.1/preview.json b/tests/fixtures/stored-state/v0.1.1/preview.json new file mode 100644 index 0000000..216d612 --- /dev/null +++ b/tests/fixtures/stored-state/v0.1.1/preview.json @@ -0,0 +1 @@ +{"formatVersion":1,"createdUnixSeconds":1767225600,"expiresUnixSeconds":1767312000,"workspacePath":"/fixture/workspace","authorizationDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","recoveryManifestDigest":null,"preview":{"previewId":"prv_11111111111111111111111111111111","workspaceUri":"file:///fixture/workspace/","server":"rust","sessionIdentity":"sid_2222222222222222222222222222222222222222222222222222222222222222","positionEncoding":"utf-16","expiresAt":"2026-01-02T00:00:00Z","source":{"kind":"workspace_edit"},"summary":{"filesChanged":1,"textEdits":1,"creates":0,"renames":0,"deletes":0},"edit":{"changes":{"file:///fixture/workspace/main.rs":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":3}},"newText":"new"}]}},"annotations":{},"plan":{"operations":[{"kind":"text","index":0,"uri":"file:///fixture/workspace/main.rs","path":"/fixture/workspace/main.rs","beforeDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","afterDigest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","edits":[{"startByte":0,"endByte":3,"newText":"new"}]}],"beforeManifest":[{"path":"/fixture/workspace/main.rs","exists":true,"resourceKind":"file","identityDigest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","contentDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","metadataDigest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}],"intendedManifest":[{"path":"/fixture/workspace/main.rs","exists":true,"resourceKind":"file","identityDigest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","contentDigest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","metadataDigest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}]},"preconditions":[{"path":"/fixture/workspace/main.rs","exists":true,"resourceKind":"file","identityDigest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","contentDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","metadataDigest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}],"conflicts":[],"staleReasons":[],"reserved":false}} diff --git a/tests/fixtures/stored-state/v0.1.1/receipt.json b/tests/fixtures/stored-state/v0.1.1/receipt.json new file mode 100644 index 0000000..aa6f1fc --- /dev/null +++ b/tests/fixtures/stored-state/v0.1.1/receipt.json @@ -0,0 +1 @@ +{"formatVersion":1,"completedUnixSeconds":1767225600,"expiresUnixSeconds":1769817600,"receipt":{"receiptId":"prv_11111111111111111111111111111111","kind":"receipt","transactionId":"txn_33333333333333333333333333333333","workspaceUri":"file:///fixture/workspace/","server":"rust","sessionIdentity":"sid_2222222222222222222222222222222222222222222222222222222222222222","previewId":"prv_11111111111111111111111111111111","preauthorized":false,"startedAt":"2026-01-01T00:00:00Z","completedAt":"2026-01-01T00:00:01Z","outcome":"applied","filesystemState":"changed","summary":{"filesChanged":1,"textEdits":1,"creates":0,"renames":0,"deletes":0},"beforeManifest":[],"intendedManifest":[],"observedManifest":[],"sessionSynchronized":true,"cleanupPending":false,"durability":{"fileFlush":true,"directoryFlush":"best_effort"},"manifestDigest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}} diff --git a/tests/fixtures/stored-state/v0.1.1/recovery.json b/tests/fixtures/stored-state/v0.1.1/recovery.json new file mode 100644 index 0000000..53b31c4 --- /dev/null +++ b/tests/fixtures/stored-state/v0.1.1/recovery.json @@ -0,0 +1 @@ +{"formatVersion":1,"transactionId":"txn_33333333333333333333333333333333","previewId":"prv_11111111111111111111111111111111","receiptId":"prv_11111111111111111111111111111111","workspacePath":"/fixture/workspace","workspaceUri":"file:///fixture/workspace/","state":"recovery_required","startedAt":"2026-01-01T00:00:00Z","artifactDirectory":"/fixture/workspace/.lspctl-txn_33333333333333333333333333333333","backups":[{"path":"/fixture/workspace/main.rs","backupPath":"/fixture/workspace/.lspctl-txn_33333333333333333333333333333333/backup-0","existed":true,"resourceKind":"file"}],"beforeManifest":[],"intendedManifest":[],"observedManifest":[],"manifestDigest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","cleanupPending":true} diff --git a/tests/fixtures/stored-state/v0.1.1/trust.json b/tests/fixtures/stored-state/v0.1.1/trust.json new file mode 100644 index 0000000..02a0ad8 --- /dev/null +++ b/tests/fixtures/stored-state/v0.1.1/trust.json @@ -0,0 +1 @@ +{"formatVersion":1,"records":[{"workspaceUri":"file:///fixture/workspace/","server":"rust","state":"trusted","declarationDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","fieldDigests":{"executable":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"executablePath":"/fixture/bin/rust-analyzer","sourcePath":"/fixture/workspace/.lspctl.toml","updatedAt":"2026-01-01T00:00:00Z"},{"workspaceUri":"file:///fixture/workspace/","server":"denied","state":"denied","declarationDigest":null,"fieldDigests":{},"executablePath":null,"sourcePath":"/fixture/workspace/.lspctl.toml","updatedAt":"2026-01-01T00:00:00Z"}]} diff --git a/tests/release_scripts.py b/tests/release_scripts.py index 79f8f80..557420e 100644 --- a/tests/release_scripts.py +++ b/tests/release_scripts.py @@ -7,6 +7,7 @@ import json import os import platform +import shutil import subprocess import sys import tarfile @@ -43,6 +44,22 @@ def load_script(path: Path): class ReleaseArchiveTests(unittest.TestCase): + def test_stored_state_checks_every_prior_release_seed(self) -> None: + checker = load_script(ROOT / "scripts/release/check_stored_state.py") + with tempfile.TemporaryDirectory() as temporary: + fixtures = Path(temporary) / "stored-state" + shutil.copytree(checker.ROOT, fixtures) + with mock.patch.object(checker, "ROOT", fixtures): + checker.main() + for directory in ("v1", "v0.1.1"): + with self.subTest(directory=directory): + preview = fixtures / directory / "preview.json" + original = preview.read_bytes() + preview.write_bytes(original + b"\n") + with self.assertRaisesRegex(SystemExit, "fixture changed"): + checker.main() + preview.write_bytes(original) + @unittest.skipUnless(sys.platform == "win32", "PowerShell validation runs on Windows") def test_windows_installer_parses(self) -> None: subprocess.run( diff --git a/tests/skill_install.rs b/tests/skill_install.rs index 8ef3043..f0c98a6 100644 --- a/tests/skill_install.rs +++ b/tests/skill_install.rs @@ -12,7 +12,7 @@ fn local_install_is_managed_idempotent_and_replace_requires_consent() { assert_eq!(installed["command"], json!(["skill", "install"])); assert_eq!(installed["result"]["scope"], "local"); assert_eq!(installed["result"]["outcome"], "installed"); - assert_eq!(installed["result"]["skillVersion"], "0.1.1"); + assert_eq!(installed["result"]["skillVersion"], "0.1.2"); assert_eq!(installed["result"]["previousDigest"], Value::Null); let destination = workspace.path().join(".agent/skills/lspctl"); let marker: Value =