Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "lspctl"
version = "0.1.1"
version = "0.1.2"
edition = "2024"
rust-version = "1.89"
default-run = "lspctl"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion assets/contract/catalog.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"binaryVersion": "0.1.1",
"binaryVersion": "0.1.2",
"contractVersion": 1,
"configVersion": 1,
"capabilityProfileVersion": 1,
Expand Down
12 changes: 9 additions & 3 deletions docs/release-acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions docs/releases/v0.1.2.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion release-gates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 13 additions & 7 deletions scripts/release/check_stored_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
16 changes: 9 additions & 7 deletions src/configuration/trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
38 changes: 21 additions & 17 deletions src/mutation/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion tests/cli_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/stored-state/v0.1.1/manifest.json
Original file line number Diff line number Diff line change
@@ -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"}
1 change: 1 addition & 0 deletions tests/fixtures/stored-state/v0.1.1/preview.json
Original file line number Diff line number Diff line change
@@ -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}}
1 change: 1 addition & 0 deletions tests/fixtures/stored-state/v0.1.1/receipt.json
Original file line number Diff line number Diff line change
@@ -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"}}
1 change: 1 addition & 0 deletions tests/fixtures/stored-state/v0.1.1/recovery.json
Original file line number Diff line number Diff line change
@@ -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}
1 change: 1 addition & 0 deletions tests/fixtures/stored-state/v0.1.1/trust.json
Original file line number Diff line number Diff line change
@@ -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"}]}
17 changes: 17 additions & 0 deletions tests/release_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import os
import platform
import shutil
import subprocess
import sys
import tarfile
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/skill_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading