From 1773860d32fffff1feb46808463f5a7937c546af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:52:12 +0000 Subject: [PATCH 01/21] chore(deps): bump sigstore/cosign-installer from 3.9.1 to 4.1.2 Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 3.9.1 to 4.1.2. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/398d4b0eeef1380460a10c8013a76f728fb906ac...6f9f17788090df1f26f669e9d70d6ae9567deba6) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-version: 4.1.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bae6f17..4375f220 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -292,7 +292,7 @@ jobs: ls -la release/ - name: install cosign - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 - name: sign release archives run: | @@ -404,7 +404,7 @@ jobs: contents: read steps: - name: install cosign - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 - name: download published assets env: From 79280756219b8416b8332b46476673f4798703b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:52:17 +0000 Subject: [PATCH 02/21] chore(deps): bump softprops/action-gh-release from 2.6.2 to 3.0.0 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.6.2 to 3.0.0. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/3bb12739c298aeb8a4eeaf626c5b8d85266b0e65...b4309332981a82ec1c5618f44dd2e27cc8bfbfda) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bae6f17..9fbd6000 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -378,7 +378,7 @@ jobs: echo "notes-file=NOTES.md" >> "$GITHUB_OUTPUT" - name: create release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda with: tag_name: ${{ github.ref_name }} name: Clarion ${{ github.ref_name }} From 0ba00985e6cba826609572c4719b4faae4273b33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:52:56 +0000 Subject: [PATCH 03/21] chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/docs.yml | 2 +- .github/workflows/release.yml | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b447b447..dddfd985 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: name: Rust runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: # Full history so the ADR-024 migration-retirement guard can resolve # the published_build.txt ref via `git show :0001` (the marker @@ -123,7 +123,7 @@ jobs: - target: aarch64-apple-darwin runner: macos-14 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: @@ -144,7 +144,7 @@ jobs: name: Python plugin runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: @@ -186,7 +186,7 @@ jobs: runs-on: ubuntu-latest needs: [rust, python-plugin] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e35daed8..89383254 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,7 +27,7 @@ jobs: name: Build + deploy runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: fetch-depth: 0 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af6d4e5a..92314f1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: name: Verify (pre-release gates) runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: fetch-depth: 0 @@ -157,7 +157,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 - name: enforce repository release controls env: @@ -185,7 +185,7 @@ jobs: - target: aarch64-apple-darwin runner: macos-14 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: @@ -239,7 +239,7 @@ jobs: name: Build Python plugin sdist runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: @@ -295,7 +295,7 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: fetch-depth: 0 From 9ece6f2104241f11dbed70fe72483d30a8697e79 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:27:22 +0000 Subject: [PATCH 04/21] =?UTF-8?q?=E2=9A=A1=20Bolt:=20optimize=20`local=5Fw?= =?UTF-8?q?eighted=5Fcomponents`=20clustering=20memory=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By borrowing string slices (`&str`) instead of eagerly deep cloning `String` module IDs inside the `neighbors` map, `seen` set, and traversal `stack`, we avoid hundreds of unnecessary allocations during the graph fallback clustering algorithm. Final ownership (`.to_owned()`) is now only assumed exactly when a module is confirmed and assigned to its returned partition. Co-authored-by: tachyon-beep <544926+tachyon-beep@users.noreply.github.com> --- .jules/bolt.md | 3 +++ crates/clarion-analysis/src/lib.rs | 26 +++++++++++++------------- 2 files changed, 16 insertions(+), 13 deletions(-) create mode 100644 .jules/bolt.md diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 00000000..2c736dc8 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,3 @@ +## 2026-06-04 - Borrowing strings during clustering graph traversal +**Learning:** In `crates/clarion-analysis/src/lib.rs`, the fallback algorithm `local_weighted_components` cloned module string IDs deeply while populating neighbor lists (`neighbors`), seen sets (`seen`), and graph traversal stacks (`stack`). For a typical graph with hundreds or thousands of nodes, this causes extensive unnecessary memory allocations and CPU overhead during clustering. +**Action:** Replace `String` with `&str` references inside internal clustering structures. Rust's borrow checker can perfectly track the lifetimes bound to the original `ModuleGraph`, and `.to_owned()` only needs to be called when pushing a module into the final partitioned `Vec` results. This dramatically reduces heap allocations. diff --git a/crates/clarion-analysis/src/lib.rs b/crates/clarion-analysis/src/lib.rs index 6ae90ce8..6e2f2f30 100644 --- a/crates/clarion-analysis/src/lib.rs +++ b/crates/clarion-analysis/src/lib.rs @@ -170,43 +170,43 @@ fn local_weighted_components(graph: &ModuleGraph, min_cluster_size: usize) -> Ve } let threshold = average_positive_weight(graph).max(1.0); - let modules = graph.modules.iter().cloned().collect::>(); + let modules = graph.modules.iter().map(String::as_str).collect::>(); let mut neighbors = modules .iter() - .map(|module_id| (module_id.clone(), BTreeSet::new())) + .map(|&module_id| (module_id, BTreeSet::new())) .collect::>(); for edge in &graph.edges { if reference_weight(edge.reference_count) >= threshold - && modules.contains(&edge.from) - && modules.contains(&edge.to) + && modules.contains(edge.from.as_str()) + && modules.contains(edge.to.as_str()) { neighbors - .entry(edge.from.clone()) + .entry(edge.from.as_str()) .or_default() - .insert(edge.to.clone()); + .insert(edge.to.as_str()); neighbors - .entry(edge.to.clone()) + .entry(edge.to.as_str()) .or_default() - .insert(edge.from.clone()); + .insert(edge.from.as_str()); } } let mut seen = BTreeSet::new(); let mut communities = Vec::new(); for module_id in modules { - if !seen.insert(module_id.clone()) { + if !seen.insert(module_id) { continue; } let mut stack = vec![module_id]; let mut community = Vec::new(); while let Some(current) = stack.pop() { - community.push(current.clone()); + community.push(current.to_owned()); if let Some(next) = neighbors.get(¤t) { - for neighbor in next.iter().rev() { - if seen.insert(neighbor.clone()) { - stack.push(neighbor.clone()); + for &neighbor in next.iter().rev() { + if seen.insert(neighbor) { + stack.push(neighbor); } } } From c844493f5c456d45d081193af2897e9ddfee1bc5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:37:00 +0000 Subject: [PATCH 05/21] =?UTF-8?q?=E2=9A=A1=20Bolt:=20optimize=20`local=5Fw?= =?UTF-8?q?eighted=5Fcomponents`=20clustering=20memory=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By borrowing string slices (`&str`) instead of eagerly deep cloning `String` module IDs inside the `neighbors` map, `seen` set, and traversal `stack`, we avoid hundreds of unnecessary allocations during the graph fallback clustering algorithm. Final ownership (`.to_owned()`) is now only assumed exactly when a module is confirmed and assigned to its returned partition. Co-authored-by: tachyon-beep <544926+tachyon-beep@users.noreply.github.com> --- crates/clarion-analysis/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/clarion-analysis/src/lib.rs b/crates/clarion-analysis/src/lib.rs index 6e2f2f30..e101d350 100644 --- a/crates/clarion-analysis/src/lib.rs +++ b/crates/clarion-analysis/src/lib.rs @@ -170,7 +170,11 @@ fn local_weighted_components(graph: &ModuleGraph, min_cluster_size: usize) -> Ve } let threshold = average_positive_weight(graph).max(1.0); - let modules = graph.modules.iter().map(String::as_str).collect::>(); + let modules = graph + .modules + .iter() + .map(String::as_str) + .collect::>(); let mut neighbors = modules .iter() .map(|&module_id| (module_id, BTreeSet::new())) From 765a385de2cf1daa9afa375b347b4dc25beb6cd9 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Fri, 5 Jun 2026 03:05:52 +1000 Subject: [PATCH 06/21] fix: treat enrich-only integration bindings as warning, not gate failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code-review findings on the dogfood-integration commits. Issue #1 (federation-axiom leak): `clarion doctor` mapped BindingState::MissingOrStale to a `problem`, the only severity that fails the gate (exit 1). Since binding_state() reports MissingOrStale whenever the full three-way Clarion+Filigree+Wardline config is absent — including a legitimate Clarion-solo or Clarion+Filigree-only project — this made an enrich-only sibling effectively required, contradicting loom.md §5. Both the JSON and text doctor paths now report a `warning` for missing/stale bindings; Unparseable and --fix repair failures remain `problem`. Adds a Tally{problems,warnings} type + warn() helper so the text summary no longer claims "All orientation surfaces healthy" alongside a warning. Tests updated: bare doctor on a no-bindings project now exits 0 with the warning surfaced; the skill-only project reports "2 problems found". Issue #2 (doc accuracy): contracts.md oversold a per-row locator fallback. Clarified that Clarion sends one key per entity (SEI xor locator), with no per-row fallback — a legacy locator-keyed row for a SEI-bearing entity is not resolved until the SEI migration re-keys it. Minors: removed an unreachable duplicate `issue_cap_truncated` break in graph.rs; documented the dual-key association_aliases map in lib.rs. Verified: fmt, clippy -D warnings (clarion-cli + clarion-mcp), nextest doctor (7/7) and storage_tools (97/97). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/clarion-cli/src/doctor.rs | 91 ++++++++++++++++++++------- crates/clarion-cli/tests/doctor.rs | 25 +++++--- crates/clarion-mcp/src/lib.rs | 5 ++ crates/clarion-mcp/src/tools/graph.rs | 5 +- docs/federation/contracts.md | 19 ++++-- 5 files changed, 105 insertions(+), 40 deletions(-) diff --git a/crates/clarion-cli/src/doctor.rs b/crates/clarion-cli/src/doctor.rs index 67ba117c..3ece0f50 100644 --- a/crates/clarion-cli/src/doctor.rs +++ b/crates/clarion-cli/src/doctor.rs @@ -9,10 +9,17 @@ //! The repair for each is that module's idempotent installer, so //! `doctor --fix` and `clarion install` converge to the same state. //! -//! Output is a per-surface ✓/✗ report followed by the index snapshot (reused +//! Output is a per-surface ✓/⚠/✗ report followed by the index snapshot (reused //! verbatim from the session-start hook). [`run`] returns whether every surface //! is healthy *after* any repairs; the caller maps an unhealthy result to a //! non-zero exit so `doctor` is usable as a CI / pre-commit gate. +//! +//! Severity is deliberate. The Loom three-way integration bindings are an +//! *enrich-only* surface (per `docs/suite/loom.md` §5): a Clarion-solo or +//! Clarion+Filigree-only project is first-class, so their absence is a +//! **warning** (surfaced, suggests `--fix`) and never a problem that fails the +//! gate. Only a genuinely broken state — an unparseable config file, or a +//! `--fix` repair that errors or does not converge — is a problem. use std::env; use std::fs; @@ -56,29 +63,36 @@ pub fn run(path: &Path, fix: bool, json_output: bool) -> Result { println!("clarion doctor{}", if fix { " --fix" } else { "" }); - let mut problems = 0usize; - problems += check_skill(&project_root, fix); - problems += check_hook(&project_root, fix); - problems += check_mcp(&project_root, fix); - problems += check_integration_bindings(&project_root, fix); + let mut tally = Tally::default(); + tally += check_skill(&project_root, fix); + tally += check_hook(&project_root, fix); + tally += check_mcp(&project_root, fix); + tally += check_integration_bindings(&project_root, fix); println!("--- index ---"); for line in hook::snapshot_report(&project_root) { println!("{line}"); } - if problems == 0 { + if tally.problems == 0 && tally.warnings == 0 { println!("All orientation surfaces healthy."); + } else if tally.problems == 0 { + let plural = if tally.warnings == 1 { "" } else { "s" }; + println!( + "{} warning{plural}; no problems (run with --fix to wire optional surfaces).", + tally.warnings + ); } else { let suffix = if fix { "." } else { " (run with --fix to repair)." }; - let plural = if problems == 1 { "" } else { "s" }; - println!("{problems} problem{plural} found{suffix}"); + let plural = if tally.problems == 1 { "" } else { "s" }; + println!("{} problem{plural} found{suffix}", tally.problems); } - Ok(problems == 0) + // Only problems fail the gate; warnings are advisory (enrich-only surfaces). + Ok(tally.problems == 0) } #[derive(Debug, Serialize)] @@ -459,7 +473,8 @@ fn check_integration_bindings_json(project_root: &Path, fix: bool) -> DoctorJson BindingState::MissingOrStale => { let what = "three-way integration bindings missing or stale"; if !fix { - return DoctorJsonCheck::problem("integration.bindings", what); + // Enrich-only surface: absence is a warning, not a gate failure. + return DoctorJsonCheck::warning("integration.bindings", what); } match integration_bindings::install_bindings(project_root) { Ok(_) @@ -486,22 +501,53 @@ fn read_clarion_yaml(project_root: &Path) -> Option { serde_norway::from_str(&raw).ok() } -/// Print one healthy line and return 0. -fn ok(line: &str) -> usize { +/// Per-check severity tally for the text report. Only `problems` fail the gate; +/// `warnings` are surfaced but advisory (enrich-only / optional surfaces). +#[derive(Default)] +struct Tally { + problems: usize, + warnings: usize, +} + +impl std::ops::AddAssign for Tally { + fn add_assign(&mut self, rhs: Self) { + self.problems += rhs.problems; + self.warnings += rhs.warnings; + } +} + +/// Print one healthy line; contributes nothing to the tally. +fn ok(line: &str) -> Tally { println!(" ✓ {line}"); - 0 + Tally::default() } -/// Print one problem line (plus an optional fix hint) and return 1. -fn problem(line: &str, fix_hint: Option<&str>) -> usize { +/// Print one warning line (plus an optional fix hint). Surfaced but advisory — +/// does not fail the gate. +fn warn(line: &str, fix_hint: Option<&str>) -> Tally { + println!(" ⚠ {line}"); + if let Some(hint) = fix_hint { + println!(" fix: {hint}"); + } + Tally { + problems: 0, + warnings: 1, + } +} + +/// Print one problem line (plus an optional fix hint). Fails the gate. +fn problem(line: &str, fix_hint: Option<&str>) -> Tally { println!(" ✗ {line}"); if let Some(hint) = fix_hint { println!(" fix: {hint}"); } - 1 + Tally { + problems: 1, + warnings: 0, + } } -fn check_skill(project_root: &Path, fix: bool) -> usize { +fn check_skill(project_root: &Path, fix: bool) -> Tally { match skill_pack::skill_pack_state(project_root) { SkillPackState::UpToDate => ok("skill pack up to date (.claude + .agents)"), state => { @@ -532,7 +578,7 @@ fn check_skill(project_root: &Path, fix: bool) -> usize { } } -fn check_hook(project_root: &Path, fix: bool) -> usize { +fn check_hook(project_root: &Path, fix: bool) -> Tally { match hooks_settings::session_start_hook_state(project_root) { HookState::Present => ok("SessionStart hook present (.claude/settings.json)"), // An unparseable settings.json is never auto-repaired — the merge @@ -565,7 +611,7 @@ fn check_hook(project_root: &Path, fix: bool) -> usize { } } -fn check_mcp(project_root: &Path, fix: bool) -> usize { +fn check_mcp(project_root: &Path, fix: bool) -> Tally { match mcp_registration::mcp_entry_state(project_root) { McpState::Present => ok(".mcp.json clarion serve entry present"), McpState::Unparseable => problem( @@ -595,7 +641,7 @@ fn check_mcp(project_root: &Path, fix: bool) -> usize { } } -fn check_integration_bindings(project_root: &Path, fix: bool) -> usize { +fn check_integration_bindings(project_root: &Path, fix: bool) -> Tally { match integration_bindings::binding_state(project_root) { BindingState::Present => { ok("three-way integration bindings present (Clarion + Filigree + Wardline)") @@ -607,7 +653,8 @@ fn check_integration_bindings(project_root: &Path, fix: bool) -> usize { BindingState::MissingOrStale => { let what = "three-way integration bindings missing or stale"; if !fix { - return problem(what, Some("clarion doctor --fix")); + // Enrich-only surface: absence is a warning, not a gate failure. + return warn(what, Some("clarion doctor --fix")); } match integration_bindings::install_bindings(project_root) { Ok(_) diff --git a/crates/clarion-cli/tests/doctor.rs b/crates/clarion-cli/tests/doctor.rs index 667d4335..6840759f 100644 --- a/crates/clarion-cli/tests/doctor.rs +++ b/crates/clarion-cli/tests/doctor.rs @@ -175,12 +175,17 @@ fn doctor_fix_repairs_missing_three_way_integration_bindings() { let (code, out) = doctor(dir.path(), false); assert_eq!( - code, 1, - "missing integration bindings should be unhealthy:\n{out}" + code, 0, + "missing enrich-only integration bindings must NOT fail the gate (federation axiom: \ + Wardline is enrich-only, a Clarion-solo/Filigree-only project is first-class):\n{out}" ); assert!( - out.contains("three-way integration bindings missing or stale"), - "stdout:\n{out}" + out.contains("⚠ three-way integration bindings missing or stale"), + "missing bindings should surface as a warning, not a problem:\n{out}" + ); + assert!( + out.contains("1 warning; no problems"), + "summary should report the warning without claiming a problem:\n{out}" ); let (code, out) = doctor(dir.path(), true); @@ -298,8 +303,9 @@ fn doctor_fix_json_reports_fixed_config_bindings() { } /// With only the skill installed (no hook, no mcp, no integration bindings), -/// `doctor` reports the missing surfaces and exits 1; the index snapshot block -/// is still printed. +/// `doctor` exits 1 on the genuine problems (missing hook + mcp) while the +/// enrich-only integration bindings surface only as a warning; the index +/// snapshot block is still printed. #[test] fn doctor_reports_missing_hook_and_mcp_and_prints_index_block() { let dir = tempfile::tempdir().unwrap(); @@ -314,9 +320,10 @@ fn doctor_reports_missing_hook_and_mcp_and_prints_index_block() { "stdout:\n{out}" ); assert!( - out.contains("three-way integration bindings missing or stale"), - "stdout:\n{out}" + out.contains("⚠ three-way integration bindings missing or stale"), + "enrich-only bindings should be a warning, not a problem:\n{out}" ); assert!(out.contains("--- index ---"), "stdout:\n{out}"); - assert!(out.contains("3 problems found"), "stdout:\n{out}"); + // Only the hook and mcp surfaces are genuine problems; bindings is a warning. + assert!(out.contains("2 problems found"), "stdout:\n{out}"); } diff --git a/crates/clarion-mcp/src/lib.rs b/crates/clarion-mcp/src/lib.rs index 3da3e493..305288d4 100644 --- a/crates/clarion-mcp/src/lib.rs +++ b/crates/clarion-mcp/src/lib.rs @@ -1694,6 +1694,11 @@ struct IssuesForAccumulator { impl IssuesForAccumulator { fn new(entities: &[EntityRow], entity_json_by_id: HashMap) -> Self { + // Map every key Filigree might echo back in `clarion_entity_id` to the + // current locator (`entity.id`). A SEI-bearing entity is queried by SEI + // only (see `tool_issues_for`), so the SEI→locator alias is the live + // path for those rows; the locator self-mapping covers no-SEI entities + // and any straggler locator-keyed rows during the SEI migration window. let mut association_aliases = HashMap::new(); for entity in entities { association_aliases.insert(entity.id.clone(), entity.id.clone()); diff --git a/crates/clarion-mcp/src/tools/graph.rs b/crates/clarion-mcp/src/tools/graph.rs index 0bfccc63..99f8bd4e 100644 --- a/crates/clarion-mcp/src/tools/graph.rs +++ b/crates/clarion-mcp/src/tools/graph.rs @@ -475,6 +475,8 @@ impl ServerState { }; requests_total += 1; accumulator.add_response(response); + // Stop if this response itself overflowed the issue cap, or if we've + // hit the cap and there are still entities left to query. if accumulator.issue_cap_truncated { break; } @@ -482,9 +484,6 @@ impl ServerState { accumulator.issue_cap_truncated = true; break; } - if accumulator.issue_cap_truncated { - break; - } } // Enrich matched/drifted entries with each issue's title/status/priority. // Every unique issue is fetched exactly once (the accumulator already diff --git a/docs/federation/contracts.md b/docs/federation/contracts.md index 152b8e8f..a80073ab 100644 --- a/docs/federation/contracts.md +++ b/docs/federation/contracts.md @@ -1281,12 +1281,19 @@ This pins the Filigree reverse-association route Clarion consumes for GET {filigree_base}/api/entity-associations?entity_id={association_key} ``` -`association_key` is opaque to Filigree. Current Clarion callers use the -entity's SEI (`clarion:eid:*`) when the served index has one, and fall back to -the mutable locator (`{plugin}:{kind}:{qualname}`) only for pre-SEI or unbound -indexes. The response field remains `clarion_entity_id` for wire compatibility; -new rows normally echo the SEI key, while legacy rows may echo a locator. -Clarion maps a returned SEI key back to the current locator before comparing +`association_key` is opaque to Filigree. Clarion sends exactly **one** key per +entity: the entity's SEI (`clarion:eid:*`) when the served index has one, and +the mutable locator (`{plugin}:{kind}:{qualname}`) only for entities with no SEI +(pre-SEI or unbound indexes). There is no per-row fallback: once an entity has a +SEI, Clarion queries by SEI alone, so a *legacy locator-keyed* Filigree row for +that same entity is **not** resolved until the SEI migration re-keys it onto the +SEI (per [`sei-migration-playbook.md`](./sei-migration-playbook.md); bindings +"must key on the SEI" — see [§SEI identity resolution](#sei-identity-resolution)). +The locator query path therefore applies solely to entities that never had a SEI. + +The response field remains `clarion_entity_id` for wire compatibility; a SEI +query echoes the SEI key, a locator query echoes the locator. Clarion maps a +returned SEI key back to the current locator before comparing `content_hash_at_attach` against the current entity content hash. ## Consumed Filigree route: issue detail (enrichment) From 1076753f13d8d4f826ef7f629f2c8932d1b60da0 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:23:37 +1000 Subject: [PATCH 07/21] feat(plugin): read Wardline descriptor metadata --- CHANGELOG.md | 8 + Cargo.lock | 16 +- Cargo.toml | 2 +- crates/clarion-cli/Cargo.toml | 14 +- crates/clarion-federation/Cargo.toml | 2 +- crates/clarion-mcp/Cargo.toml | 6 +- crates/clarion-plugin-fixture/Cargo.toml | 2 +- crates/clarion-storage/Cargo.toml | 2 +- .../adr/ADR-018-identity-reconciliation.md | 24 ++- docs/suite/loom.md | 2 +- ...ked-wardline-annotation-metadata-design.md | 59 ++++++ plugins/python/README.md | 6 +- plugins/python/plugin.toml | 13 +- plugins/python/pyproject.toml | 4 +- .../src/clarion_plugin_python/__init__.py | 2 +- .../src/clarion_plugin_python/extractor.py | 73 ++++++- .../src/clarion_plugin_python/server.py | 14 +- .../wardline_descriptor.py | 184 ++++++++++++++++++ plugins/python/tests/test_extractor.py | 117 ++++++++++- plugins/python/tests/test_package.py | 11 +- plugins/python/tests/test_server.py | 97 ++++++++- .../python/tests/test_wardline_descriptor.py | 167 ++++++++++++++++ plugins/python/uv.lock | 15 +- scripts/check-wardline-version-bounds.py | 155 ++++++--------- 24 files changed, 844 insertions(+), 151 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-05-descriptor-backed-wardline-annotation-metadata-design.md create mode 100644 plugins/python/src/clarion_plugin_python/wardline_descriptor.py create mode 100644 plugins/python/tests/test_wardline_descriptor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c20e59..73ad1e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ only when an incompatible change is made to that surface. See ### Changed +- **Python plugin Wardline descriptor metadata (ADR-018 Revision 3).** The + Python plugin now reads Wardline's NG-25 trust-vocabulary descriptor from + `.wardline/vocabulary.yaml` or the installed `wardline/core/vocabulary.yaml` + data file without importing Wardline. Decorated functions/classes receive + `wardline` entity metadata and `wardline:*` tags when the descriptor is + available; missing or invalid descriptors degrade to normal structural + extraction. This retires the Clarion-side `wardline.core.registry` startup + asterisk in `docs/suite/loom.md`. - Refreshed release-facing README/index documentation for the current 1.2.0 release line, including the 39-tool MCP surface, current install artifact names, fixed ADR/docset links, current web/operator quick starts, and the full diff --git a/Cargo.lock b/Cargo.lock index d1fd8be2..f7aa7efe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -312,7 +312,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clarion-analysis" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "serde", @@ -322,7 +322,7 @@ dependencies = [ [[package]] name = "clarion-cli" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "assert_cmd", @@ -361,7 +361,7 @@ dependencies = [ [[package]] name = "clarion-core" -version = "1.2.0" +version = "1.3.0" dependencies = [ "async-trait", "nix", @@ -378,7 +378,7 @@ dependencies = [ [[package]] name = "clarion-federation" -version = "1.2.0" +version = "1.3.0" dependencies = [ "clarion-core", "reqwest", @@ -391,7 +391,7 @@ dependencies = [ [[package]] name = "clarion-mcp" -version = "1.2.0" +version = "1.3.0" dependencies = [ "async-trait", "blake3", @@ -414,7 +414,7 @@ dependencies = [ [[package]] name = "clarion-plugin-fixture" -version = "1.2.0" +version = "1.3.0" dependencies = [ "clarion-core", "nix", @@ -423,7 +423,7 @@ dependencies = [ [[package]] name = "clarion-scanner" -version = "1.2.0" +version = "1.3.0" dependencies = [ "regex", "serde", @@ -435,7 +435,7 @@ dependencies = [ [[package]] name = "clarion-storage" -version = "1.2.0" +version = "1.3.0" dependencies = [ "blake3", "clarion-core", diff --git a/Cargo.toml b/Cargo.toml index ca8b29b7..5198071a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "1.2.0" +version = "1.3.0" edition = "2024" license = "MIT" repository = "https://github.com/tachyon-beep/clarion" diff --git a/crates/clarion-cli/Cargo.toml b/crates/clarion-cli/Cargo.toml index e31a5038..1f7c8867 100644 --- a/crates/clarion-cli/Cargo.toml +++ b/crates/clarion-cli/Cargo.toml @@ -18,12 +18,12 @@ anyhow.workspace = true axum.workspace = true blake3.workspace = true clap.workspace = true -clarion-core = { path = "../clarion-core", version = "1.2.0" } -clarion-analysis = { path = "../clarion-analysis", version = "1.2.0" } -clarion-federation = { path = "../clarion-federation", version = "1.2.0" } -clarion-mcp = { path = "../clarion-mcp", version = "1.2.0" } -clarion-scanner = { path = "../clarion-scanner", version = "1.2.0" } -clarion-storage = { path = "../clarion-storage", version = "1.2.0" } +clarion-core = { path = "../clarion-core", version = "1.3.0" } +clarion-analysis = { path = "../clarion-analysis", version = "1.3.0" } +clarion-federation = { path = "../clarion-federation", version = "1.3.0" } +clarion-mcp = { path = "../clarion-mcp", version = "1.3.0" } +clarion-scanner = { path = "../clarion-scanner", version = "1.3.0" } +clarion-storage = { path = "../clarion-storage", version = "1.3.0" } dotenvy.workspace = true fs2.workspace = true hmac.workspace = true @@ -46,7 +46,7 @@ uuid.workspace = true [dev-dependencies] assert_cmd.workspace = true -clarion-plugin-fixture = { path = "../clarion-plugin-fixture", version = "1.2.0" } +clarion-plugin-fixture = { path = "../clarion-plugin-fixture", version = "1.3.0" } rusqlite.workspace = true serde_json.workspace = true sha1.workspace = true diff --git a/crates/clarion-federation/Cargo.toml b/crates/clarion-federation/Cargo.toml index df0b4a37..1cc05352 100644 --- a/crates/clarion-federation/Cargo.toml +++ b/crates/clarion-federation/Cargo.toml @@ -10,7 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] -clarion-core = { path = "../clarion-core", version = "1.2.0" } +clarion-core = { path = "../clarion-core", version = "1.3.0" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/clarion-mcp/Cargo.toml b/crates/clarion-mcp/Cargo.toml index b6f1e1a1..9955ff29 100644 --- a/crates/clarion-mcp/Cargo.toml +++ b/crates/clarion-mcp/Cargo.toml @@ -12,9 +12,9 @@ workspace = true [dependencies] async-trait.workspace = true blake3.workspace = true -clarion-core = { path = "../clarion-core", version = "1.2.0" } -clarion-federation = { path = "../clarion-federation", version = "1.2.0" } -clarion-storage = { path = "../clarion-storage", version = "1.2.0" } +clarion-core = { path = "../clarion-core", version = "1.3.0" } +clarion-federation = { path = "../clarion-federation", version = "1.3.0" } +clarion-storage = { path = "../clarion-storage", version = "1.3.0" } reqwest.workspace = true rusqlite.workspace = true serde.workspace = true diff --git a/crates/clarion-plugin-fixture/Cargo.toml b/crates/clarion-plugin-fixture/Cargo.toml index 08745947..6f89a0a9 100644 --- a/crates/clarion-plugin-fixture/Cargo.toml +++ b/crates/clarion-plugin-fixture/Cargo.toml @@ -14,7 +14,7 @@ name = "clarion-plugin-fixture" path = "src/main.rs" [dependencies] -clarion-core = { path = "../clarion-core", version = "1.2.0" } +clarion-core = { path = "../clarion-core", version = "1.3.0" } serde_json.workspace = true [target.'cfg(unix)'.dependencies] diff --git a/crates/clarion-storage/Cargo.toml b/crates/clarion-storage/Cargo.toml index e08a6360..5a05bd81 100644 --- a/crates/clarion-storage/Cargo.toml +++ b/crates/clarion-storage/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] blake3.workspace = true -clarion-core = { path = "../clarion-core", version = "1.2.0" } +clarion-core = { path = "../clarion-core", version = "1.3.0" } deadpool-sqlite.workspace = true rusqlite.workspace = true serde.workspace = true diff --git a/docs/clarion/adr/ADR-018-identity-reconciliation.md b/docs/clarion/adr/ADR-018-identity-reconciliation.md index e306a129..7b14ef3e 100644 --- a/docs/clarion/adr/ADR-018-identity-reconciliation.md +++ b/docs/clarion/adr/ADR-018-identity-reconciliation.md @@ -1,6 +1,6 @@ # ADR-018: Identity Reconciliation — Clarion Translates; Wardline Owns Its Qualnames -**Status**: Accepted +**Status**: Accepted (Revision 3, 2026-06-05 — direct REGISTRY import asterisk retired; Python plugin reads Wardline's NG-25 descriptor without importing Wardline.) **Date**: 2026-04-18 **Deciders**: qacona@gmail.com **Context**: three independent identity schemes exist across Clarion, Wardline, and Wardline's exception register; one-way translation is the federation-compatible answer @@ -93,6 +93,28 @@ The REGISTRY import is a property of the Wardline-aware plugin specifically, not This preserves the federation test: removing Wardline breaks Wardline-derived annotation detection but does not prevent the Clarion core from starting, does not prevent non-Wardline-aware plugins from running, and does not alter the meaning of Clarion's own catalog entries. +### Revision 3 (2026-06-05): direct-import asterisk retired + +Wardline now publishes the NG-25 trust-vocabulary descriptor as `vocabulary.yaml` +and through `wardline vocab`. Clarion's Python plugin consumes that descriptor +instead of importing `wardline.core.registry.REGISTRY`. Resolution is +project-local `.wardline/vocabulary.yaml` first, then the installed Wardline +distribution data file `wardline/core/vocabulary.yaml`; both paths are plain +file reads and neither imports `wardline`, `wardline.core`, or +`wardline.core.registry`. + +The plugin records source-observed decorator facts on Clarion entities as +Wardline metadata and `wardline:*` tags. Wardline remains authoritative for the +vocabulary and policy semantics; Clarion stores only what it parsed from source +against the descriptor. Missing or invalid descriptors continue to degrade +honestly: normal structural extraction proceeds, `capabilities.wardline` reports +the degraded state, and no Wardline entity metadata is emitted. + +This closes the `loom.md` §5 initialization-coupling asterisk on the Clarion +side. The identity translation rules and `REGISTRY_VERSION`/descriptor-version +skew posture remain bilateral compatibility concerns; the load-bearing change is +that plugin startup no longer requires Wardline to be importable. + ## Alternatives Considered ### Alternative 1: Wardline adopts Clarion's entity-ID scheme diff --git a/docs/suite/loom.md b/docs/suite/loom.md index 65adf0c2..ac446b43 100644 --- a/docs/suite/loom.md +++ b/docs/suite/loom.md @@ -67,7 +67,7 @@ A "standalone mode" that works only because an invisible sibling is still import The v1.0 suite does not pass the expanded failure test cleanly. Two specific couplings are named here so they cannot drift unnoticed: - **Wardline→Filigree findings are pipeline-coupled through Clarion in v1.0.** Wardline's SARIF output reaches Filigree only via Clarion's `clarion sarif import` translator. This violates pipeline composability for the (Wardline, Filigree) pair. *Retirement condition* (mechanism updated 2026-05-29): the generic Wardline rebuild ships a **native Filigree emitter** (Wardline-side, per the 2026-05-29 integration brief and Clarion's ADR-015 Revision 2), at which point the pair composes directly and Clarion drops off the transport path — its `clarion sarif import` translator stays as the general-purpose SARIF path for other tools; only its Wardline-bridge role retires. The asterisk is **kept live** until that emitter ships and (Wardline, Filigree) composition is verified with Clarion absent — agreement to the direction is not retirement. Tracked under `release:1.1`. -- **Clarion's Python plugin imports `wardline.core.registry.REGISTRY` at startup.** This is initialization coupling scoped to the Wardline-aware plugin specifically, not to Clarion as a product — Clarion's core and any non-Wardline-aware plugins do not depend on Wardline being importable. The coupling is named so it does not slip unexamined into a future general-purpose plugin. If a future plugin introduces similar initialization coupling without a clear "this plugin is specifically about Wardline" justification, it violates this rule. *Retirement condition* (per ADR-018): when Wardline publishes a language-neutral YAML/JSON descriptor export of its REGISTRY (NG-25), the coupling retires to a plain file-descriptor read — ADR-018 is *simplified, not superseded* (the qualname translation layer and `REGISTRY_VERSION` pin semantics are unchanged). **Status (2026-05-30):** the Wardline-side prerequisite is **met** — Wardline has shipped the NG-25 descriptor (Wardline SP4 / SP2d), and the generic rebuild confirmedly does **not** preserve the `wardline.core.registry` direct-import surface (`clarion-22acf15fd7`). The asterisk is nonetheless **kept live** until Clarion's Python plugin migrates off the direct import to the descriptor read — confirming the rebuild's effect on the import surface is not retirement. Tracked under `release:1.1` as `clarion-1f6241b329`. +- **Retired 2026-06-05 — Clarion's Python plugin imported `wardline.core.registry.REGISTRY` at startup.** This was initialization coupling scoped to the Wardline-aware plugin specifically, not to Clarion as a product. The retirement condition was met in two parts: Wardline shipped the NG-25 trust-vocabulary descriptor, and Clarion's Python plugin now reads that descriptor (`.wardline/vocabulary.yaml` first, then the installed `wardline/core/vocabulary.yaml` data file) without importing `wardline`, `wardline.core`, or `wardline.core.registry`. Clarion records only source-observed decorator metadata on its own entities; Wardline remains authoritative for vocabulary and policy semantics. See ADR-018 Revision 3. The asterisk is no longer live on the Clarion side. Asterisks are acceptable only with a written retirement condition and an honest statement of which failure-test mode is being temporarily violated. A "we'll fix it later" without a test-mode citation is not an asterisk; it is the stealth-monolith failure mode wearing different clothes. diff --git a/docs/superpowers/specs/2026-06-05-descriptor-backed-wardline-annotation-metadata-design.md b/docs/superpowers/specs/2026-06-05-descriptor-backed-wardline-annotation-metadata-design.md new file mode 100644 index 00000000..e256966d --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-descriptor-backed-wardline-annotation-metadata-design.md @@ -0,0 +1,59 @@ +# Descriptor-Backed Wardline Annotation Metadata + +## Summary + +Clarion's Python plugin consumes Wardline's NG-25 trust-vocabulary descriptor +without importing Wardline. When the descriptor is available, the plugin records +source-observed Wardline decorator facts on Clarion function/class entities as +metadata and tags. Wardline remains authoritative for vocabulary and policy +semantics; Clarion stores only what it observes in source against that +descriptor. + +## Design + +The plugin resolves the descriptor once during `initialize`: + +1. `/.wardline/vocabulary.yaml` +2. installed Wardline distribution data file `wardline/core/vocabulary.yaml` +3. absent/degraded state + +Descriptor resolution uses package metadata and file reads only. The plugin +must not import `wardline`, `wardline.core`, or `wardline.core.registry` on the +startup path. + +`capabilities.wardline` reports `enabled`, `version_skew`, or `absent`. Missing, +unreadable, malformed, or duplicate-entry descriptors do not abort analysis. +Normal structural extraction continues and no Wardline entity metadata is +emitted. + +For matched decorators, the plugin attaches a `wardline` object to the emitted +entity: + +- `descriptor_version` +- `confidence_basis` (`descriptor` or `descriptor_version_skew`) +- `decorators[]` with canonical name, qualified source name, group, attrs, and + line + +The same entity gets denormalized tags: `wardline` and +`wardline:`. + +Decorator matching uses only the final qualified-name segment. This supports +bare, imported, and qualified forms without interpreting decorator arguments. + +## Non-Goals + +- No Rust storage schema change. +- No first-class decorator entities or decorator edges. +- No taint-level interpretation of decorator arguments. +- No Filigree finding emission for ordinary decorator observations. + +## Verification + +- Unit tests cover descriptor resolution, invalid descriptors, duplicates, + version skew, and no-import regression behavior. +- Extractor tests cover direct, qualified, stacked, absent, and version-skew + decorator metadata. +- Server tests cover initialize capabilities and vocabulary threading into + `analyze_file`. +- Manifest/version guard tests cover `wardline_aware=true`, the descriptor pin, + and ontology/version lockstep. diff --git a/plugins/python/README.md b/plugins/python/README.md index 09d345f6..6b7cb41b 100644 --- a/plugins/python/README.md +++ b/plugins/python/README.md @@ -6,8 +6,10 @@ JSON-RPC protocol defined in [WP2 L4](../../docs/implementation/sprint-1/wp2-plu **Status**: Python structural extractor. It emits modules, classes, functions, `contains`, `calls`, `references`, `imports`, and versioned entity signatures -for Stable Entity Identity (SEI) matching. Wardline semantic enrichment is not -advertised until the plugin emits real Wardline-derived signals. +for Stable Entity Identity (SEI) matching. It also reads Wardline's NG-25 +trust-vocabulary descriptor without importing Wardline and emits source-observed +Wardline decorator metadata/tags on decorated entities when a descriptor is +available. ## Install (development) diff --git a/plugins/python/plugin.toml b/plugins/python/plugin.toml index bb5a9fa7..4501e020 100644 --- a/plugins/python/plugin.toml +++ b/plugins/python/plugin.toml @@ -1,7 +1,7 @@ [plugin] name = "clarion-plugin-python" plugin_id = "python" -version = "1.2.0" +version = "1.3.0" protocol_version = "1.0" # Bare basename per ADR-021 §Layer 1 + WP2 scrub commit eb0a41d — the host # refuses manifests whose `executable` carries any path component. @@ -19,9 +19,9 @@ expected_max_rss_mb = 2048 # core cap (warning emission itself is deferred to Tier B — Sprint 1 # only lands the declaration). expected_entities_per_file = 5000 -# Wardline semantic extraction is not implemented in this plugin yet. Keep this -# false until the plugin emits usable Wardline-derived signals. -wardline_aware = false +# Wardline semantic extraction reads the NG-25 vocabulary descriptor without +# importing Wardline, then emits source-observed decorator metadata on entities. +wardline_aware = true # v0.1 rejects `true` at initialize with CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY. reads_outside_project_root = false @@ -44,7 +44,10 @@ rule_id_prefix = "CLA-PY-" # guidance_fingerprint) — ontology_version is handshake-validation, NOT a # cache-key component. New edge rows miss the cache by component-1 of # the 5-tuple organically (no edges live in the 5-tuple yet anyway). -ontology_version = "0.6.0" +ontology_version = "0.7.0" + +[integrations.wardline] +expected_descriptor_version = "wardline-generic-2" [ontology.roles] file_scope = ["module"] diff --git a/plugins/python/pyproject.toml b/plugins/python/pyproject.toml index 73469406..576b9e12 100644 --- a/plugins/python/pyproject.toml +++ b/plugins/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "clarion-plugin-python" -version = "1.2.0" +version = "1.3.0" description = "Clarion Python language plugin — v1.0 release" readme = "README.md" requires-python = ">=3.11" @@ -18,6 +18,7 @@ classifiers = [ dependencies = [ "packaging>=24", "pyright==1.1.409", + "pyyaml>=6.0", ] [project.optional-dependencies] @@ -29,6 +30,7 @@ dev = [ "pre-commit>=3.8", "pip-audit>=2.9", "build>=1.2", + "types-PyYAML>=6.0", ] [project.scripts] diff --git a/plugins/python/src/clarion_plugin_python/__init__.py b/plugins/python/src/clarion_plugin_python/__init__.py index 645029c3..5346d05c 100644 --- a/plugins/python/src/clarion_plugin_python/__init__.py +++ b/plugins/python/src/clarion_plugin_python/__init__.py @@ -1,3 +1,3 @@ """clarion-plugin-python — Python language plugin for Clarion.""" -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/plugins/python/src/clarion_plugin_python/extractor.py b/plugins/python/src/clarion_plugin_python/extractor.py index b7e97613..75cd650e 100644 --- a/plugins/python/src/clarion_plugin_python/extractor.py +++ b/plugins/python/src/clarion_plugin_python/extractor.py @@ -60,7 +60,7 @@ import time from dataclasses import dataclass, field from pathlib import PurePosixPath -from typing import Literal, NotRequired, TypedDict, cast +from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, cast from clarion_plugin_python.call_resolver import ( CallResolutionResult, @@ -80,6 +80,9 @@ ReferenceSite, ) +if TYPE_CHECKING: + from clarion_plugin_python.wardline_descriptor import WardlineVocabulary + _PLUGIN_ID = "python" _NOOP_CALL_RESOLVER = NoOpCallResolver() _NOOP_REFERENCE_RESOLVER = NoOpReferenceResolver() @@ -145,6 +148,20 @@ class ClassSignature(TypedDict): bases: list[str] +class WardlineDecoratorMetadata(TypedDict): + canonical_name: str + qualified_name: str + group: int + attrs: dict[str, str] + line: int + + +class WardlineEntityMetadata(TypedDict): + descriptor_version: str + confidence_basis: Literal["descriptor", "descriptor_version_skew"] + decorators: list[WardlineDecoratorMetadata] + + class RawEntity(TypedDict): """Wire shape matching the Rust host's RawEntity contract. @@ -179,6 +196,9 @@ class RawEntity(TypedDict): tags: NotRequired[list[str]] # Short natural-language text used by analyze-time semantic embeddings. docstring: NotRequired[str] + # Wardline descriptor-backed source-observed decorator facts. Wardline owns + # the vocabulary; Clarion stores only the annotation facts seen on entities. + wardline: NotRequired[WardlineEntityMetadata] class RawEdge(TypedDict): @@ -321,13 +341,14 @@ def _build_module_entity( return entity -def extract( +def extract( # noqa: PLR0913 - resolver seams + optional Wardline vocabulary are caller-owned. source: str, file_path: str, *, module_prefix_path: str | None = None, call_resolver: CallResolver = _NOOP_CALL_RESOLVER, reference_resolver: ReferenceResolver = _NOOP_REFERENCE_RESOLVER, + wardline_vocabulary: WardlineVocabulary | None = None, ) -> tuple[list[RawEntity], list[RawEdge]]: result = extract_with_stats( source, @@ -335,17 +356,19 @@ def extract( module_prefix_path=module_prefix_path, call_resolver=call_resolver, reference_resolver=reference_resolver, + wardline_vocabulary=wardline_vocabulary, ) return result.entities, result.edges -def extract_with_stats( +def extract_with_stats( # noqa: PLR0913 - resolver seams + optional Wardline vocabulary are caller-owned. source: str, file_path: str, *, module_prefix_path: str | None = None, call_resolver: CallResolver = _NOOP_CALL_RESOLVER, reference_resolver: ReferenceResolver = _NOOP_REFERENCE_RESOLVER, + wardline_vocabulary: WardlineVocabulary | None = None, ) -> ExtractResult: """Return extracted entities/edges plus resolver observability stats. @@ -413,6 +436,7 @@ def extract_with_stats( seen_ids={module_entity["id"]}, file_path=file_path, exported_names=_module_export_names(tree), + wardline_vocabulary=wardline_vocabulary, ) _walk( tree, @@ -863,6 +887,7 @@ class _WalkState: seen_ids: set[str] file_path: str + wardline_vocabulary: WardlineVocabulary | None = None exported_names: set[str] = field(default_factory=set) duplicate_entities_dropped: int = 0 @@ -1045,6 +1070,40 @@ def _attach_optional_entity_metadata( entity["tags"] = sorted(tags) +def _attach_wardline_entity_metadata( + entity: RawEntity, + node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, + tags: set[str], + vocabulary: WardlineVocabulary | None, +) -> None: + if vocabulary is None: + return + decorators: list[WardlineDecoratorMetadata] = [] + for decorator in node.decorator_list: + qualified_name = _expr_qualified_name(decorator) + if qualified_name is None: + continue + entry = vocabulary.entry_for_decorator(qualified_name) + if entry is None: + continue + decorators.append( + { + "canonical_name": entry.canonical_name, + "qualified_name": qualified_name, + "group": entry.group, + "attrs": dict(entry.attrs), + "line": decorator.lineno, + }, + ) + tags.update({"wardline", f"wardline:{entry.canonical_name}"}) + if decorators: + entity["wardline"] = { + "descriptor_version": vocabulary.version, + "confidence_basis": vocabulary.confidence_basis, + "decorators": decorators, + } + + def _module_export_names(tree: ast.Module) -> set[str]: exported: set[str] = set() for statement in tree.body: @@ -1196,10 +1255,12 @@ def _build_function_entity( "definition": definition, "signature": _function_signature(node), } + tags = _function_tags(node, parents, state.exported_names) + _attach_wardline_entity_metadata(entity, node, tags, state.wardline_vocabulary) _attach_optional_entity_metadata( entity, docstring=ast.get_docstring(node), - tags=_function_tags(node, parents, state.exported_names), + tags=tags, ) return entity, child_id @@ -1241,9 +1302,11 @@ def _build_class_entity( "definition": definition, "signature": _class_signature(node), } + tags = _class_tags(node, parents, state.exported_names) + _attach_wardline_entity_metadata(entity, node, tags, state.wardline_vocabulary) _attach_optional_entity_metadata( entity, docstring=ast.get_docstring(node), - tags=_class_tags(node, parents, state.exported_names), + tags=tags, ) return entity, child_id diff --git a/plugins/python/src/clarion_plugin_python/server.py b/plugins/python/src/clarion_plugin_python/server.py index 7c6f629b..5b33e29d 100644 --- a/plugins/python/src/clarion_plugin_python/server.py +++ b/plugins/python/src/clarion_plugin_python/server.py @@ -31,8 +31,9 @@ from clarion_plugin_python.extractor import extract_with_stats from clarion_plugin_python.pyright_session import PyrightRunState, PyrightSession from clarion_plugin_python.stdout_guard import install_stdio +from clarion_plugin_python.wardline_descriptor import WardlineVocabulary, load_wardline_descriptor -ONTOLOGY_VERSION = "0.6.0" +ONTOLOGY_VERSION = "0.7.0" # Plugin-side Content-Length sanity cap. Matches the host's ADR-021 §2b # default (8 MiB) so the plugin never emits a frame the host would kill us @@ -61,6 +62,7 @@ class ServerState: pyright: PyrightSession | None = field(default=None) pyright_files_since_restart: int = 0 pyright_run_state: PyrightRunState = field(default_factory=PyrightRunState) + wardline_vocabulary: WardlineVocabulary | None = field(default=None) def read_frame(stream: IO[bytes]) -> dict[str, Any] | None: @@ -142,11 +144,18 @@ def handle_initialize(params: dict[str, Any], state: ServerState) -> dict[str, A root_raw = params.get("project_root") if isinstance(root_raw, str) and root_raw: state.project_root = Path(root_raw).resolve() + wardline = load_wardline_descriptor(state.project_root) + state.wardline_vocabulary = wardline.vocabulary + if wardline.status == "absent": + sys.stderr.write( + "clarion-plugin-python: Wardline vocabulary descriptor unavailable; " + "continuing without Wardline annotation metadata\n", + ) return { "name": "clarion-plugin-python", "version": __version__, "ontology_version": ONTOLOGY_VERSION, - "capabilities": {}, + "capabilities": {"wardline": wardline.as_capability()}, } @@ -209,6 +218,7 @@ def handle_analyze_file(params: dict[str, Any], state: ServerState) -> dict[str, module_prefix_path=module_prefix, call_resolver=state.pyright, reference_resolver=state.pyright, + wardline_vocabulary=state.wardline_vocabulary, ) state.pyright_files_since_restart += 1 if state.pyright_files_since_restart >= MAX_FILES_PER_PYRIGHT_SESSION: diff --git a/plugins/python/src/clarion_plugin_python/wardline_descriptor.py b/plugins/python/src/clarion_plugin_python/wardline_descriptor.py new file mode 100644 index 00000000..2c03e60f --- /dev/null +++ b/plugins/python/src/clarion_plugin_python/wardline_descriptor.py @@ -0,0 +1,184 @@ +"""Wardline NG-25 vocabulary descriptor reader. + +This module deliberately reads descriptor files without importing Wardline. +Wardline remains authoritative for the vocabulary; Clarion records only the +source-observed decorator facts it can derive from that descriptor. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib import metadata +from pathlib import Path +from typing import Any, Literal, cast + +import yaml + +EXPECTED_DESCRIPTOR_VERSION = "wardline-generic-2" +PROJECT_DESCRIPTOR_PATH = Path(".wardline/vocabulary.yaml") + +DescriptorSource = Literal["project", "package"] +DescriptorStatus = Literal["enabled", "version_skew", "absent"] + + +@dataclass(frozen=True) +class DescriptorEntry: + canonical_name: str + group: int + attrs: dict[str, str] + + +@dataclass(frozen=True) +class WardlineVocabulary: + version: str + source: DescriptorSource + confidence_basis: Literal["descriptor", "descriptor_version_skew"] + entries_by_name: dict[str, DescriptorEntry] + + def entry_for_decorator(self, qualified_name: str) -> DescriptorEntry | None: + return self.entries_by_name.get(qualified_name.rsplit(".", 1)[-1]) + + +@dataclass(frozen=True) +class WardlineDescriptorState: + status: DescriptorStatus + expected_version: str = EXPECTED_DESCRIPTOR_VERSION + descriptor_version: str | None = None + source: DescriptorSource | None = None + reason: str | None = None + vocabulary: WardlineVocabulary | None = None + + def as_capability(self) -> dict[str, str]: + if self.status == "absent": + capability = {"status": "absent"} + if self.reason: + capability["reason"] = self.reason + return capability + capability = { + "status": self.status, + "descriptor_version": self.descriptor_version or "", + "source": self.source or "", + } + if self.status == "version_skew": + capability["expected_version"] = self.expected_version + return capability + + +class _DescriptorError(ValueError): + pass + + +def load_wardline_descriptor(project_root: Path | None) -> WardlineDescriptorState: + """Resolve and parse the Wardline descriptor, degrading on every failure.""" + project_text = _read_project_descriptor(project_root) + if project_text is not None: + return _state_from_text(project_text, "project") + + package_text = _read_package_descriptor() + if package_text is not None: + return _state_from_text(package_text, "package") + + return WardlineDescriptorState(status="absent", reason="not_found") + + +def _read_project_descriptor(project_root: Path | None) -> str | None: + if project_root is None: + return None + path = project_root / PROJECT_DESCRIPTOR_PATH + if not path.is_file(): + return None + try: + return path.read_text(encoding="utf-8") + except OSError: + return None + + +def _read_package_descriptor() -> str | None: + try: + files = metadata.files("wardline") + except metadata.PackageNotFoundError: + return None + if files is None: + return None + for package_file in files: + if str(package_file).replace("\\", "/").endswith("wardline/core/vocabulary.yaml"): + try: + return cast("str", cast("Any", package_file.locate()).read_text(encoding="utf-8")) + except OSError: + return None + return None + + +def _state_from_text(text: str, source: DescriptorSource) -> WardlineDescriptorState: + try: + descriptor = yaml.safe_load(text) + vocabulary = _parse_descriptor(descriptor, source) + except (OSError, yaml.YAMLError, _DescriptorError): + return WardlineDescriptorState(status="absent", reason="invalid_descriptor") + if vocabulary.version != EXPECTED_DESCRIPTOR_VERSION: + return WardlineDescriptorState( + status="version_skew", + descriptor_version=vocabulary.version, + source=source, + vocabulary=WardlineVocabulary( + version=vocabulary.version, + source=source, + confidence_basis="descriptor_version_skew", + entries_by_name=vocabulary.entries_by_name, + ), + ) + return WardlineDescriptorState( + status="enabled", + descriptor_version=vocabulary.version, + source=source, + vocabulary=vocabulary, + ) + + +def _parse_descriptor(descriptor: Any, source: DescriptorSource) -> WardlineVocabulary: + if not isinstance(descriptor, dict): + msg = "descriptor root must be a mapping" + raise _DescriptorError(msg) + version = descriptor.get("version") + entries = descriptor.get("entries") + if not isinstance(version, str) or not isinstance(entries, list): + msg = "descriptor must carry string version and list entries" + raise _DescriptorError(msg) + + entries_by_name: dict[str, DescriptorEntry] = {} + for raw_entry in entries: + entry = _parse_entry(raw_entry) + if entry.canonical_name in entries_by_name: + msg = f"duplicate Wardline descriptor entry: {entry.canonical_name}" + raise _DescriptorError(msg) + entries_by_name[entry.canonical_name] = entry + return WardlineVocabulary( + version=version, + source=source, + confidence_basis="descriptor", + entries_by_name=entries_by_name, + ) + + +def _parse_entry(raw_entry: Any) -> DescriptorEntry: + if not isinstance(raw_entry, dict): + msg = "descriptor entry must be a mapping" + raise _DescriptorError(msg) + canonical_name = raw_entry.get("canonical_name") + group = raw_entry.get("group") + attrs = raw_entry.get("attrs") + if not isinstance(canonical_name, str) or not isinstance(group, int): + msg = "descriptor entry must carry canonical_name and group" + raise _DescriptorError(msg) + if not isinstance(attrs, dict): + msg = "descriptor entry attrs must be a mapping" + raise _DescriptorError(msg) + for key, value in attrs.items(): + if not isinstance(key, str) or not isinstance(value, str): + msg = "descriptor attrs must map strings to strings" + raise _DescriptorError(msg) + return DescriptorEntry( + canonical_name=canonical_name, + group=group, + attrs=cast("dict[str, str]", dict(attrs)), + ) diff --git a/plugins/python/tests/test_extractor.py b/plugins/python/tests/test_extractor.py index fa8ed789..c8e02b61 100644 --- a/plugins/python/tests/test_extractor.py +++ b/plugins/python/tests/test_extractor.py @@ -7,7 +7,7 @@ import sys import textwrap from pathlib import Path -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Literal, cast import pytest @@ -23,6 +23,7 @@ ) from clarion_plugin_python.pyright_session import PyrightSession from clarion_plugin_python.reference_resolver import ReferenceResolutionResult, ReferenceSite +from clarion_plugin_python.wardline_descriptor import DescriptorEntry, WardlineVocabulary if TYPE_CHECKING: from collections.abc import Sequence @@ -990,6 +991,120 @@ class Config: assert "data-model" in config["tags"] +def _wardline_vocabulary( + *, + confidence_basis: Literal["descriptor", "descriptor_version_skew"] = "descriptor", +) -> WardlineVocabulary: + return WardlineVocabulary( + version="wardline-generic-2", + source="project", + confidence_basis=confidence_basis, + entries_by_name={ + "external_boundary": DescriptorEntry( + canonical_name="external_boundary", + group=1, + attrs={}, + ), + "trust_boundary": DescriptorEntry( + canonical_name="trust_boundary", + group=1, + attrs={"_wardline_to_level": "TaintState"}, + ), + "trusted": DescriptorEntry( + canonical_name="trusted", + group=1, + attrs={"_wardline_level": "TaintState"}, + ), + }, + ) + + +def test_wardline_vocabulary_attaches_decorator_metadata_and_tags() -> None: + source = """\ +from loom_markers import external_boundary, trust_boundary, trusted + +@external_boundary +def read_body(): + return "" + +@loom_markers.trust_boundary(to_level="ASSURED") +@trusted(level="INTEGRAL") +class Sanitizer: + pass +""" + + entities, _ = extract(source, "service.py", wardline_vocabulary=_wardline_vocabulary()) + + read_body = next(e for e in entities if e["id"] == "python:function:service.read_body") + sanitizer = next(e for e in entities if e["id"] == "python:class:service.Sanitizer") + + assert read_body["wardline"] == { + "descriptor_version": "wardline-generic-2", + "confidence_basis": "descriptor", + "decorators": [ + { + "canonical_name": "external_boundary", + "qualified_name": "external_boundary", + "group": 1, + "attrs": {}, + "line": 3, + }, + ], + } + assert "wardline" in read_body["tags"] + assert "wardline:external_boundary" in read_body["tags"] + + assert sanitizer["wardline"]["decorators"] == [ + { + "canonical_name": "trust_boundary", + "qualified_name": "loom_markers.trust_boundary", + "group": 1, + "attrs": {"_wardline_to_level": "TaintState"}, + "line": 7, + }, + { + "canonical_name": "trusted", + "qualified_name": "trusted", + "group": 1, + "attrs": {"_wardline_level": "TaintState"}, + "line": 8, + }, + ] + assert "wardline:trust_boundary" in sanitizer["tags"] + assert "wardline:trusted" in sanitizer["tags"] + + +def test_wardline_absent_preserves_plain_extraction_without_metadata() -> None: + source = """\ +@trusted +def compute(): + return 1 +""" + + entities, _ = extract(source, "service.py", wardline_vocabulary=None) + + compute = next(e for e in entities if e["id"] == "python:function:service.compute") + assert "wardline" not in compute + assert "tags" not in compute or "wardline" not in compute["tags"] + + +def test_wardline_version_skew_marks_degraded_confidence() -> None: + source = """\ +@trusted +def compute(): + return 1 +""" + + entities, _ = extract( + source, + "service.py", + wardline_vocabulary=_wardline_vocabulary(confidence_basis="descriptor_version_skew"), + ) + + compute = next(e for e in entities if e["id"] == "python:function:service.compute") + assert compute["wardline"]["confidence_basis"] == "descriptor_version_skew" + + def test_module_source_range_no_trailing_newline() -> None: """File ending without `\\n` still produces correct end_line. diff --git a/plugins/python/tests/test_package.py b/plugins/python/tests/test_package.py index 95b1e8bd..c430cec5 100644 --- a/plugins/python/tests/test_package.py +++ b/plugins/python/tests/test_package.py @@ -16,7 +16,7 @@ def _read_toml(path: Path) -> dict[str, Any]: def test_package_version_matches_pyproject() -> None: - assert clarion_plugin_python.__version__ == "1.2.0" + assert clarion_plugin_python.__version__ == "1.3.0" def test_plugin_version_lockstep_across_pyproject_manifest_and_module() -> None: @@ -41,9 +41,12 @@ def test_plugin_version_lockstep_across_pyproject_manifest_and_module() -> None: def test_manifest_declares_current_v1_ontology_only() -> None: manifest = _read_toml(_PLUGIN_ROOT / "plugin.toml") - assert manifest["plugin"]["version"] == "1.2.0" - assert manifest["capabilities"]["runtime"]["wardline_aware"] is False - assert manifest["ontology"]["ontology_version"] == "0.6.0" + assert manifest["plugin"]["version"] == "1.3.0" + assert manifest["capabilities"]["runtime"]["wardline_aware"] is True + assert manifest["integrations"]["wardline"]["expected_descriptor_version"] == ( + "wardline-generic-2" + ) + assert manifest["ontology"]["ontology_version"] == "0.7.0" assert manifest["ontology"]["entity_kinds"] == ["function", "class", "module"] assert manifest["ontology"]["edge_kinds"] == ["contains", "calls", "references", "imports"] assert "decorated_by" not in manifest["ontology"]["edge_kinds"] diff --git a/plugins/python/tests/test_server.py b/plugins/python/tests/test_server.py index eac8af27..dce33f3c 100644 --- a/plugins/python/tests/test_server.py +++ b/plugins/python/tests/test_server.py @@ -86,11 +86,14 @@ def test_initialize_roundtrip() -> None: assert response["id"] == 1 result = response["result"] assert result["name"] == "clarion-plugin-python" - assert result["version"] == "1.2.0" - assert result["ontology_version"] == "0.6.0" - # Wardline is not advertised until the plugin emits real Wardline - # semantic signals. - assert result["capabilities"] == {} + assert result["version"] == "1.3.0" + assert result["ontology_version"] == "0.7.0" + assert set(result["capabilities"]) == {"wardline"} + assert result["capabilities"]["wardline"]["status"] in { + "absent", + "enabled", + "version_skew", + } # Graceful shutdown: shutdown → ack `{}`, then exit notification. proc.stdin.write( @@ -245,6 +248,90 @@ def bar(self): proc.wait(timeout=2) +def test_initialize_project_descriptor_reports_wardline_enabled(tmp_path: Path) -> None: + descriptor = tmp_path / ".wardline" / "vocabulary.yaml" + descriptor.parent.mkdir() + descriptor.write_text( + """\ +version: wardline-generic-2 +entries: +- canonical_name: trusted + group: 1 + attrs: + _wardline_level: TaintState +""", + encoding="utf-8", + ) + state = server_module.ServerState() + + response = server_module.handle_initialize( + {"protocol_version": "1.0", "project_root": str(tmp_path)}, + state, + ) + + assert response["capabilities"]["wardline"] == { + "status": "enabled", + "descriptor_version": "wardline-generic-2", + "source": "project", + } + assert state.wardline_vocabulary is not None + + +def test_analyze_file_threads_wardline_vocabulary( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakePyrightSession: + def __init__(self, project_root: Path, **_kwargs: Any) -> None: + self.project_root = project_root + + def resolve_calls( + self, + file_path: str, + function_ids: list[str], + ) -> CallResolutionResult: + _ = (file_path, function_ids) + return CallResolutionResult() + + def resolve_references( + self, + file_path: str, + sites: Sequence[ReferenceSite], + ) -> ReferenceResolutionResult: + _ = (file_path, sites) + return ReferenceResolutionResult() + + def close(self) -> None: + pass + + monkeypatch.setattr(server_module, "PyrightSession", FakePyrightSession, raising=False) + descriptor = tmp_path / ".wardline" / "vocabulary.yaml" + descriptor.parent.mkdir() + descriptor.write_text( + """\ +version: wardline-generic-2 +entries: +- canonical_name: trusted + group: 1 + attrs: + _wardline_level: TaintState +""", + encoding="utf-8", + ) + demo = tmp_path / "demo.py" + demo.write_text("@trusted\ndef compute():\n return 1\n", encoding="utf-8") + state = server_module.ServerState(initialized=True) + server_module.handle_initialize( + {"protocol_version": "1.0", "project_root": str(tmp_path)}, state + ) + + response = server_module.handle_analyze_file({"file_path": str(demo)}, state) + + compute = next(e for e in response["entities"] if e["id"] == "python:function:demo.compute") + assert compute["wardline"]["decorators"][0]["canonical_name"] == "trusted" + assert "wardline:trusted" in compute["tags"] + + def test_method_not_found_returns_error() -> None: """Unknown method → -32601 response, server stays up.""" proc = subprocess.Popen( # noqa: S603 diff --git a/plugins/python/tests/test_wardline_descriptor.py b/plugins/python/tests/test_wardline_descriptor.py new file mode 100644 index 00000000..1c0bda7a --- /dev/null +++ b/plugins/python/tests/test_wardline_descriptor.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import builtins +from typing import TYPE_CHECKING, Any + +from clarion_plugin_python.wardline_descriptor import ( + EXPECTED_DESCRIPTOR_VERSION, + load_wardline_descriptor, +) + +if TYPE_CHECKING: + from pathlib import Path + + +_DESCRIPTOR = """\ +version: wardline-generic-2 +entries: +- canonical_name: external_boundary + group: 1 + attrs: {} +- canonical_name: trust_boundary + group: 1 + attrs: + _wardline_to_level: TaintState +- canonical_name: trusted + group: 1 + attrs: + _wardline_level: TaintState +""" + + +class _FakePackagePath: + def __init__(self, path: Any) -> None: + self._path = path + + def __str__(self) -> str: + return "wardline/core/vocabulary.yaml" + + def locate(self) -> Any: + return self._path + + +def test_project_descriptor_wins_over_package_descriptor( + tmp_path: Path, + monkeypatch: Any, +) -> None: + project_descriptor = tmp_path / ".wardline" / "vocabulary.yaml" + project_descriptor.parent.mkdir() + project_descriptor.write_text(_DESCRIPTOR, encoding="utf-8") + package_descriptor = tmp_path / "package-vocabulary.yaml" + package_descriptor.write_text( + _DESCRIPTOR.replace("wardline-generic-2", "wardline-generic-9"), + encoding="utf-8", + ) + monkeypatch.setattr( + "clarion_plugin_python.wardline_descriptor.metadata.files", + lambda name: [_FakePackagePath(package_descriptor)] if name == "wardline" else None, + ) + + state = load_wardline_descriptor(tmp_path) + + assert state.status == "enabled" + assert state.source == "project" + assert state.descriptor_version == EXPECTED_DESCRIPTOR_VERSION + assert state.vocabulary is not None + assert sorted(state.vocabulary.entries_by_name) == [ + "external_boundary", + "trust_boundary", + "trusted", + ] + + +def test_package_descriptor_loads_without_importing_wardline( + tmp_path: Path, + monkeypatch: Any, +) -> None: + package_descriptor = tmp_path / "vocabulary.yaml" + package_descriptor.write_text(_DESCRIPTOR, encoding="utf-8") + + real_import = builtins.__import__ + + def fail_import(name: str, *args: Any, **kwargs: Any) -> object: + if name == "wardline" or name.startswith("wardline."): + msg = f"unexpected Wardline import: {name}" + raise AssertionError(msg) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr( + "clarion_plugin_python.wardline_descriptor.metadata.files", + lambda name: [_FakePackagePath(package_descriptor)] if name == "wardline" else None, + ) + monkeypatch.setattr( + "builtins.__import__", + fail_import, + ) + + state = load_wardline_descriptor(tmp_path) + + assert state.status == "enabled" + assert state.source == "package" + assert state.vocabulary is not None + assert state.vocabulary.confidence_basis == "descriptor" + + +def test_absent_descriptor_degrades_without_vocabulary(tmp_path: Path, monkeypatch: Any) -> None: + monkeypatch.setattr( + "clarion_plugin_python.wardline_descriptor.metadata.files", + lambda _name: None, + ) + + state = load_wardline_descriptor(tmp_path) + + assert state.status == "absent" + assert state.vocabulary is None + assert state.reason == "not_found" + + +def test_invalid_descriptor_shape_degrades_to_absent(tmp_path: Path) -> None: + descriptor = tmp_path / ".wardline" / "vocabulary.yaml" + descriptor.parent.mkdir() + descriptor.write_text("version: 3\nentries: nope\n", encoding="utf-8") + + state = load_wardline_descriptor(tmp_path) + + assert state.status == "absent" + assert state.vocabulary is None + assert state.reason == "invalid_descriptor" + + +def test_duplicate_canonical_names_degrade_to_absent(tmp_path: Path) -> None: + descriptor = tmp_path / ".wardline" / "vocabulary.yaml" + descriptor.parent.mkdir() + descriptor.write_text( + """\ +version: wardline-generic-2 +entries: +- canonical_name: trusted + group: 1 + attrs: {} +- canonical_name: trusted + group: 1 + attrs: {} +""", + encoding="utf-8", + ) + + state = load_wardline_descriptor(tmp_path) + + assert state.status == "absent" + assert state.reason == "invalid_descriptor" + + +def test_version_skew_keeps_valid_vocabulary_with_degraded_confidence(tmp_path: Path) -> None: + descriptor = tmp_path / ".wardline" / "vocabulary.yaml" + descriptor.parent.mkdir() + descriptor.write_text( + _DESCRIPTOR.replace("wardline-generic-2", "wardline-generic-3"), + encoding="utf-8", + ) + + state = load_wardline_descriptor(tmp_path) + + assert state.status == "version_skew" + assert state.descriptor_version == "wardline-generic-3" + assert state.expected_version == EXPECTED_DESCRIPTOR_VERSION + assert state.vocabulary is not None + assert state.vocabulary.confidence_basis == "descriptor_version_skew" diff --git a/plugins/python/uv.lock b/plugins/python/uv.lock index 2789f2b4..233f78dc 100644 --- a/plugins/python/uv.lock +++ b/plugins/python/uv.lock @@ -196,11 +196,12 @@ wheels = [ [[package]] name = "clarion-plugin-python" -version = "1.2.0" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "packaging" }, { name = "pyright" }, + { name = "pyyaml" }, ] [package.optional-dependencies] @@ -212,6 +213,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -224,7 +226,9 @@ requires-dist = [ { name = "pyright", specifier = "==1.1.409" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, ] provides-extras = ["dev"] @@ -1030,6 +1034,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" diff --git a/scripts/check-wardline-version-bounds.py b/scripts/check-wardline-version-bounds.py index 2f12c448..c06f5af1 100755 --- a/scripts/check-wardline-version-bounds.py +++ b/scripts/check-wardline-version-bounds.py @@ -1,24 +1,15 @@ #!/usr/bin/env python3 -"""Validate the Wardline integration version bounds in the Python plugin manifest. +"""Validate the Wardline descriptor contract in the Python plugin manifest. When the Python plugin advertises Wardline semantic extraction, it declares the -Wardline version range it integrates against in ``plugins/python/plugin.toml`` -under ``[integrations.wardline]``: - -* ``min_version`` — inclusive lower bound (the oldest Wardline the plugin's - ``wardline.core.registry`` import surface is verified against). -* ``max_version`` — exclusive upper bound, deliberately set to the next major - so a future major release triggers an explicit re-pin rather than silent - drift (see the comment in plugin.toml and loom.md §5 asterisk 2). +NG-25 descriptor version it consumes in ``plugins/python/plugin.toml`` under +``[integrations.wardline].expected_descriptor_version``. This guard enforces the *local* half of the contract. If -``capabilities.runtime.wardline_aware`` is ``true``, both bounds must be -present, parse as semver, and form a sane half-open range ``[min, max)``. If the -capability is ``false``, the bounds block must be absent so dormant manifest -metadata cannot look like usable semantic integration. The *server-side* -cross-check (confirming the resolved Wardline actually advertises a version -inside the range at integration time) is future work — see -``server_side_cross_check_hook`` for the documented seam. +``capabilities.runtime.wardline_aware`` is ``true``, the descriptor version must +be present and equal to the plugin's pinned expectation. If the capability is +``false``, the integration block must be absent so dormant manifest metadata +cannot look like usable semantic integration. Closes V11-TEST-03 (docs/implementation/v1.0-tag-cut/gap-register.md). """ @@ -26,34 +17,17 @@ from __future__ import annotations import argparse -import re import sys import tempfile import tomllib from pathlib import Path DEFAULT_MANIFEST = Path("plugins/python/plugin.toml") - -# A core MAJOR.MINOR.PATCH semver, optionally with a -prerelease and/or +build. -# We only order by the numeric core, which is all the bounds contract needs. -_SEMVER_RE = re.compile( - r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" - r"(?:-(?P
[0-9A-Za-z.\-]+))?(?:\+(?P[0-9A-Za-z.\-]+))?$"
-)
+EXPECTED_DESCRIPTOR_VERSION = "wardline-generic-2"
 
 
 class CheckError(Exception):
-    """Raised when the Wardline version-bounds guard fails."""
-
-
-def parse_semver(label: str, value: object) -> tuple[int, int, int]:
-    """Parse ``value`` as a semver core triple, raising CheckError on failure."""
-    if not isinstance(value, str) or not value.strip():
-        raise CheckError(f"{label} must be a non-empty semver string, got {value!r}")
-    match = _SEMVER_RE.match(value.strip())
-    if match is None:
-        raise CheckError(f"{label} is not valid semver: {value!r}")
-    return (int(match["major"]), int(match["minor"]), int(match["patch"]))
+    """Raised when the Wardline descriptor guard fails."""
 
 
 def load_manifest(manifest_path: Path) -> dict[str, object]:
@@ -76,8 +50,8 @@ def wardline_aware(manifest_path: Path, manifest: dict[str, object]) -> bool:
     return value
 
 
-def wardline_bounds(manifest_path: Path) -> tuple[str, str] | None:
-    """Return raw (min_version, max_version), or None when capability is off."""
+def wardline_descriptor_version(manifest_path: Path) -> str | None:
+    """Return expected descriptor version, or None when capability is off."""
     manifest = load_manifest(manifest_path)
     enabled = wardline_aware(manifest_path, manifest)
     integrations = manifest.get("integrations")
@@ -101,44 +75,39 @@ def wardline_bounds(manifest_path: Path) -> tuple[str, str] | None:
             f"{manifest_path} advertises Wardline awareness but is missing "
             "[integrations.wardline]"
         ) from exc
-    missing = [key for key in ("min_version", "max_version") if key not in section]
-    if missing:
+    if "min_version" in section or "max_version" in section:
         raise CheckError(
-            f"{manifest_path} [integrations.wardline] is missing {', '.join(missing)}"
+            f"{manifest_path} [integrations.wardline] must use "
+            "expected_descriptor_version, not package min_version/max_version"
         )
-    return str(section["min_version"]), str(section["max_version"])
-
-
-def check(manifest_path: Path) -> tuple[str, str] | None:
-    """Return (min, max) if enabled, None if disabled, else raise CheckError."""
-    bounds = wardline_bounds(manifest_path)
-    if bounds is None:
-        return None
-    raw_min, raw_max = bounds
-    min_core = parse_semver("[integrations.wardline].min_version", raw_min)
-    max_core = parse_semver("[integrations.wardline].max_version", raw_max)
-    if min_core >= max_core:
+    if "expected_descriptor_version" not in section:
+        raise CheckError(
+            f"{manifest_path} [integrations.wardline] is missing "
+            "expected_descriptor_version"
+        )
+    value = section["expected_descriptor_version"]
+    if not isinstance(value, str) or not value.strip():
         raise CheckError(
-            "[integrations.wardline] bounds are not a half-open range [min, max): "
-            f"min_version={raw_min} must be strictly below max_version={raw_max}"
+            f"{manifest_path} [integrations.wardline].expected_descriptor_version "
+            f"must be a non-empty string, got {value!r}"
         )
-    return raw_min, raw_max
+    if value != EXPECTED_DESCRIPTOR_VERSION:
+        raise CheckError(
+            f"{manifest_path} expects Wardline descriptor {value!r}; "
+            f"plugin pin is {EXPECTED_DESCRIPTOR_VERSION!r}"
+        )
+    return value
 
 
-def server_side_cross_check_hook(resolved_version: str, manifest_path: Path) -> bool:
-    """Seam for the future server-side cross-check.
+def check(manifest_path: Path) -> str | None:
+    """Return expected descriptor version if enabled, None if disabled."""
+    return wardline_descriptor_version(manifest_path)
 
-    When Wardline can report its own version at integration time, the resolved
-    version should be confirmed to satisfy ``[min, max)`` here. Until then this
-    guard only enforces the locally-checkable invariants and this hook is not
-    wired into ``main``.
-    """
-    bounds = check(manifest_path)
-    if bounds is None:
-        return False
-    raw_min, raw_max = bounds
-    resolved = parse_semver("resolved Wardline version", resolved_version)
-    return parse_semver("min", raw_min) <= resolved < parse_semver("max", raw_max)
+
+def descriptor_cross_check_hook(resolved_descriptor_version: str, manifest_path: Path) -> bool:
+    """Seam for checking the runtime descriptor against the manifest pin."""
+    expected = check(manifest_path)
+    return expected is not None and resolved_descriptor_version == expected
 
 
 def write(path: Path, text: str) -> None:
@@ -151,8 +120,7 @@ def run_self_test() -> None:
         "wardline_aware = true\n"
         "\n"
         "[integrations.wardline]\n"
-        'min_version = "1.0.0"\n'
-        'max_version = "2.0.0"\n'
+        f'expected_descriptor_version = "{EXPECTED_DESCRIPTOR_VERSION}"\n'
     )
     disabled = "[capabilities.runtime]\nwardline_aware = false\n"
 
@@ -160,7 +128,7 @@ def run_self_test() -> None:
         manifest = Path(tmp) / "plugin.toml"
 
         write(manifest, aligned)
-        assert check(manifest) == ("1.0.0", "2.0.0")
+        assert check(manifest) == EXPECTED_DESCRIPTOR_VERSION
 
         write(manifest, disabled)
         assert check(manifest) is None
@@ -169,34 +137,25 @@ def run_self_test() -> None:
             manifest,
             disabled
             + "\n[integrations.wardline]\n"
-            + 'min_version = "1.0.0"\n'
-            + 'max_version = "2.0.0"\n',
+            + f'expected_descriptor_version = "{EXPECTED_DESCRIPTOR_VERSION}"\n',
         )
         _expect(manifest, "wardline_aware is false")
 
-        # Inverted bounds must fail.
-        write(
-            manifest,
-            "[capabilities.runtime]\nwardline_aware = true\n"
-            '[integrations.wardline]\nmin_version = "2.0.0"\nmax_version = "1.0.0"\n',
-        )
-        _expect(manifest, "half-open range")
-
-        # Equal bounds (empty range) must fail.
+        # Old package-version bounds must fail.
         write(
             manifest,
             "[capabilities.runtime]\nwardline_aware = true\n"
             '[integrations.wardline]\nmin_version = "1.0.0"\nmax_version = "1.0.0"\n',
         )
-        _expect(manifest, "half-open range")
+        _expect(manifest, "expected_descriptor_version")
 
-        # Non-semver bound must fail.
+        # Wrong descriptor pin must fail.
         write(
             manifest,
             "[capabilities.runtime]\nwardline_aware = true\n"
-            '[integrations.wardline]\nmin_version = "1.0" \nmax_version = "2.0.0"\n',
+            '[integrations.wardline]\nexpected_descriptor_version = "wardline-generic-9"\n',
         )
-        _expect(manifest, "not valid semver")
+        _expect(manifest, "plugin pin")
 
         # An enabled capability without bounds must fail loudly, not pass vacuously.
         write(manifest, "[capabilities.runtime]\nwardline_aware = true\n")
@@ -206,13 +165,12 @@ def run_self_test() -> None:
         write(manifest, "[ontology]\nx = 1\n")
         _expect(manifest, "missing capabilities.runtime.wardline_aware")
 
-        # The cross-check hook accepts an in-range version and rejects out-of-range.
+        # The cross-check hook accepts an exact descriptor version only.
         write(manifest, aligned)
-        assert server_side_cross_check_hook("1.4.2", manifest) is True
-        assert server_side_cross_check_hook("2.0.0", manifest) is False
-        assert server_side_cross_check_hook("0.9.0", manifest) is False
+        assert descriptor_cross_check_hook(EXPECTED_DESCRIPTOR_VERSION, manifest) is True
+        assert descriptor_cross_check_hook("wardline-generic-9", manifest) is False
 
-    print("Wardline version-bounds guard self-test passed")
+    print("Wardline descriptor guard self-test passed")
 
 
 def _expect(manifest: Path, needle: str) -> None:
@@ -226,9 +184,7 @@ def _expect(manifest: Path, needle: str) -> None:
 
 
 def main(argv: list[str]) -> int:
-    parser = argparse.ArgumentParser(
-        description="Validate Wardline integration version bounds"
-    )
+    parser = argparse.ArgumentParser(description="Validate Wardline descriptor contract")
     parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
     parser.add_argument(
         "--self-test", action="store_true", help="run built-in guard tests"
@@ -239,14 +195,13 @@ def main(argv: list[str]) -> int:
         if args.self_test:
             run_self_test()
         else:
-            bounds = check(args.manifest)
-            if bounds is None:
-                print("Wardline integration not advertised; no bounds required")
+            descriptor_version = check(args.manifest)
+            if descriptor_version is None:
+                print("Wardline integration not advertised; no descriptor pin required")
             else:
-                raw_min, raw_max = bounds
-                print(f"Wardline version bounds valid: [{raw_min}, {raw_max})")
+                print(f"Wardline descriptor pin valid: {descriptor_version}")
     except CheckError as exc:
-        print(f"Wardline version-bounds guard failed: {exc}", file=sys.stderr)
+        print(f"Wardline descriptor guard failed: {exc}", file=sys.stderr)
         return 1
     return 0
 

From 17e547db17b885f8741d3c1a2d2ed7804c175205 Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 04:39:26 +1000
Subject: [PATCH 08/21] docs(plugin): close asterisk-2 doc remainder (ADR index
 + descriptor assumptions)

Follow-up to 1076753: refresh the ADR README index line for ADR-018
Revision 3 (descriptor read; direct REGISTRY import retired) and document
the two Clarion-side descriptor assumptions pending Wardline 'Pre-Rust
core hardening' Task B (project-local path + descriptor-version/schema
semantics) in wardline_descriptor.py. Tracked/closed under clarion-881e9834bc.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 docs/clarion/adr/README.md                            |  2 +-
 .../src/clarion_plugin_python/wardline_descriptor.py  | 11 +++++++++++
 2 files changed, 12 insertions(+), 1 deletion(-)

diff --git a/docs/clarion/adr/README.md b/docs/clarion/adr/README.md
index 902bcc34..65d73792 100644
--- a/docs/clarion/adr/README.md
+++ b/docs/clarion/adr/README.md
@@ -20,7 +20,7 @@ This folder is the canonical home for authored Clarion architecture decision rec
 | [ADR-015](./ADR-015-wardline-filigree-emission.md) | Wardline→Filigree emission ownership — Clarion-side SARIF translator (v0.1), native Wardline emitter (v0.2) | Accepted |
 | [ADR-016](./ADR-016-observation-transport.md) | Observation transport — MCP-spawn (v0.1), Filigree HTTP endpoint (v0.2) | Accepted |
 | [ADR-017](./ADR-017-severity-and-dedup.md) | Severity mapping, rule-ID round-trip, and dedup policy | Accepted |
-| [ADR-018](./ADR-018-identity-reconciliation.md) | Identity reconciliation — Clarion translates; Wardline owns its qualnames; direct REGISTRY import with version pinning | Accepted |
+| [ADR-018](./ADR-018-identity-reconciliation.md) | Identity reconciliation — Clarion translates; Wardline owns its qualnames; descriptor read (direct REGISTRY import retired, Rev 3) | Accepted |
 | [ADR-021](./ADR-021-plugin-authority-hybrid.md) | Plugin authority model: hybrid (declared capabilities + core-enforced minimums) | Accepted |
 | [ADR-022](./ADR-022-core-plugin-ontology.md) | Core/plugin ontology ownership boundary | Accepted |
 | [ADR-023](./ADR-023-tooling-baseline.md) | Rust + Python tooling baseline (edition 2024, pedantic, cargo-deny, nextest, CI; ruff + mypy-strict + pre-commit) | Accepted |
diff --git a/plugins/python/src/clarion_plugin_python/wardline_descriptor.py b/plugins/python/src/clarion_plugin_python/wardline_descriptor.py
index 2c03e60f..2483e0f8 100644
--- a/plugins/python/src/clarion_plugin_python/wardline_descriptor.py
+++ b/plugins/python/src/clarion_plugin_python/wardline_descriptor.py
@@ -3,6 +3,15 @@
 This module deliberately reads descriptor files without importing Wardline.
 Wardline remains authoritative for the vocabulary; Clarion records only the
 source-observed decorator facts it can derive from that descriptor.
+
+Two contract details below (``PROJECT_DESCRIPTOR_PATH`` and the descriptor
+``version`` semantics) are Clarion-side assumptions pending Wardline's
+"Pre-Rust core hardening" Task B, which has not yet published the canonical
+project-local descriptor location or the ``schema: wardline.vocabulary/v1``
+format-version field. The parser ignores unknown top-level keys, so a future
+``schema`` field is tolerated without change; acting on it (format-version
+compatibility decisions) is deferred until Task B pins the contract. Confirm
+both assumptions against the Wardline descriptor ADR when it lands.
 """
 
 from __future__ import annotations
@@ -14,6 +23,8 @@
 
 import yaml
 
+# PO-confirm against Wardline Task B (descriptor ADR) — canonical project-local
+# location and descriptor-version semantics are not yet pinned by Wardline.
 EXPECTED_DESCRIPTOR_VERSION = "wardline-generic-2"
 PROJECT_DESCRIPTOR_PATH = Path(".wardline/vocabulary.yaml")
 

From 0a5c9d11d77e68cad64c7c57e5e02bf4eaca1a8d Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 04:53:22 +1000
Subject: [PATCH 09/21] docs(release): prepare 1.3.0 release notes + version
 refs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- CHANGELOG: promote [Unreleased] → [1.3.0] (2026-06-05); headline the
  Wardline NG-25 descriptor consumption (asterisk 2 fully retired), plus
  SEI-keyed Filigree issue lookups (Changed) and the clarion doctor
  enrich-only binding severity fix (Fixed). Add v1.2.0...v1.3.0 compare link.
- README: status/scope → 1.3.0; add the Wardline-descriptor scope bullet;
  install snippet TAG + plugin artifact → 1.3.0.
- docs/operator/getting-started.md: install snippet → 1.3.0.
- CLAUDE.md: repo-state → 1.3.0; Python-plugin line (descriptor read, not
  L8 probe); asterisk 2 marked retired, asterisk 1 still live.

Stacks 1.3.0 on the changelog-cut (untagged) 1.2.0 line.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 CHANGELOG.md                     | 52 ++++++++++++++++++++++++--------
 CLAUDE.md                        | 12 ++++----
 README.md                        | 16 +++++++---
 docs/operator/getting-started.md |  4 +--
 4 files changed, 58 insertions(+), 26 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73ad1e2d..7a41381f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,23 +12,48 @@ only when an incompatible change is made to that surface. See
 
 ## [Unreleased]
 
-### Changed
+## [1.3.0] — 2026-06-05
+
+### Added
 
-- **Python plugin Wardline descriptor metadata (ADR-018 Revision 3).** The
-  Python plugin now reads Wardline's NG-25 trust-vocabulary descriptor from
+- **Python plugin consumes Wardline's NG-25 trust-vocabulary descriptor
+  (ADR-018 Revision 3).** The Python plugin now reads Wardline's descriptor from
   `.wardline/vocabulary.yaml` or the installed `wardline/core/vocabulary.yaml`
-  data file without importing Wardline. Decorated functions/classes receive
-  `wardline` entity metadata and `wardline:*` tags when the descriptor is
-  available; missing or invalid descriptors degrade to normal structural
-  extraction. This retires the Clarion-side `wardline.core.registry` startup
-  asterisk in `docs/suite/loom.md`.
-- Refreshed release-facing README/index documentation for the current 1.2.0
-  release line, including the 39-tool MCP surface, current install artifact
-  names, fixed ADR/docset links, current web/operator quick starts, and the full
-  end-to-end verification list.
+  data file **without importing Wardline**. Functions and classes decorated with
+  Wardline trust decorators (`external_boundary` / `trust_boundary` / `trusted`)
+  receive `wardline` entity metadata and `wardline:*` tags when the descriptor is
+  available; a missing, invalid, or version-skewed descriptor degrades honestly to
+  normal structural extraction. This **fully retires** the Clarion-side
+  `wardline.core.registry` startup coupling in [`docs/suite/loom.md`](docs/suite/loom.md)
+  §5 (asterisk 2): plugin startup performs zero in-process Wardline import, so the
+  plugin no longer requires a co-installed Wardline and is robust to Wardline's
+  upcoming native core. Plugin-only change (no Rust-core / protocol / ontology
+  change); tracked at `clarion-881e9834bc`.
+
+### Changed
+
+- **Filigree issue lookups key by Stable Entity Identity (SEI).** The MCP
+  `entity_issue_list` path and the federation Filigree client resolve issues by
+  SEI rather than source locator, aligning issue enrichment with Clarion's stable
+  identity (one key per entity — SEI xor locator, no per-row fallback).
+- Refreshed release-facing README / index documentation for the 1.3.0 release
+  line, including the 39-tool MCP surface, current install artifact names, fixed
+  ADR/docset links, current web/operator quick starts, and the full end-to-end
+  verification list.
 - Archived tracked architecture-analysis working notes out of live `temp/`
   directories under `docs/archive/working-notes/`.
 
+### Fixed
+
+- **`clarion doctor` reports enrich-only integration bindings as a warning, not a
+  gate failure (federation-axiom compliance).** A missing or stale
+  Clarion+Filigree+Wardline binding previously mapped to `problem` (exit 1),
+  which made an enrich-only sibling effectively required — contradicting
+  `loom.md` §5. Both the JSON and text doctor paths now report `warning` for
+  missing/stale bindings; unparseable bindings and `--fix` repair failures remain
+  `problem`. A bare `clarion doctor` on a no-bindings (Clarion-solo or
+  Clarion+Filigree-only) project now exits 0 with the warning surfaced.
+
 ## [1.2.0] — 2026-06-03
 
 ### Added
@@ -474,7 +499,8 @@ normative.
 - Operator guides under [`docs/operator/`](docs/operator/) — getting-started,
   OpenRouter setup, HTTP read API.
 
-[Unreleased]: https://github.com/tachyon-beep/clarion/compare/v1.2.0...HEAD
+[Unreleased]: https://github.com/tachyon-beep/clarion/compare/v1.3.0...HEAD
+[1.3.0]: https://github.com/tachyon-beep/clarion/compare/v1.2.0...v1.3.0
 [1.2.0]: https://github.com/tachyon-beep/clarion/compare/v1.1.0...v1.2.0
 [1.1.0]: https://github.com/tachyon-beep/clarion/compare/v1.0.1...v1.1.0
 [1.0.1]: https://github.com/tachyon-beep/clarion/compare/v1.0.0...v1.0.1
diff --git a/CLAUDE.md b/CLAUDE.md
index 43567211..678f812c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
 
 ## Repository state
 
-**v1.2.0 — current pre-release working version.** The `v1.0.0` (first publishable release), `v1.0.1`, and `v1.1.0` tags are cut; pre-release working tags `v0.1-sprint-1` and `v0.1-sprint-2` remain in the repo as historical anchors. Workspace + Python plugin are at 1.2.0; ADR-014 federation HTTP read API ships with bearer auth, batch resolution, briefing-blocked propagation, and stable per-project `instance_id`. See [`CHANGELOG.md`](CHANGELOG.md) for the full scope and [`docs/implementation/`](docs/implementation/) for sprint-closure artifacts.
+**v1.3.0 — current pre-release working version.** The `v1.0.0` (first publishable release), `v1.0.1`, and `v1.1.0` tags are cut; pre-release working tags `v0.1-sprint-1` and `v0.1-sprint-2` remain in the repo as historical anchors. Workspace + Python plugin are at 1.3.0 (the 1.2.0 line is changelog-cut but not yet git-tagged). 1.3.0 adds descriptor-based Wardline trust-vocabulary consumption — the Python plugin reads Wardline's NG-25 descriptor instead of importing `wardline.core.registry`, fully retiring `loom.md` §5 asterisk 2 (ADR-018 Revision 3). ADR-014 federation HTTP read API ships with bearer auth, batch resolution, briefing-blocked propagation, and stable per-project `instance_id`. See [`CHANGELOG.md`](CHANGELOG.md) for the full scope and [`docs/implementation/`](docs/implementation/) for sprint-closure artifacts.
 
 ### Layout (post-1.0)
 
@@ -15,7 +15,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
   - `crates/clarion-mcp/` — the MCP protocol surface (the `clarion serve` tool catalogue, Filigree HTTP client, scan-results emission, snapshot reader).
   - `crates/clarion-scanner/` — core-owned pre-ingest secret scanner; stores only positions, rule IDs, and a detect-secrets-compatible SHA-1 digest (literal secret values never leave the scan).
   - `crates/clarion-plugin-fixture/` — test-only fixture plugin used by `wp2_e2e` integration tests.
-- **Python plugin** at `plugins/python/` (editable install: `pip install -e plugins/python[dev]`). Speaks the L4 JSON-RPC protocol; emits function entities with L7 qualnames; runs the L8 Wardline probe.
+- **Python plugin** at `plugins/python/` (editable install: `pip install -e plugins/python[dev]`). Speaks the L4 JSON-RPC protocol; emits function entities with L7 qualnames; reads Wardline's NG-25 trust-vocabulary descriptor (`wardline_descriptor.py`) to tag trust-decorated entities, without importing Wardline.
 - **Shared cross-language fixture** at `fixtures/entity_id.json` — the L2 byte-for-byte parity proof (consumed by Rust + Python tests both).
 - **End-to-end test** at `tests/e2e/sprint_1_walking_skeleton.sh` — runs the README §3 demo verbatim and asserts the sqlite output.
 - **CI** at `.github/workflows/ci.yml` — three jobs: `rust` (fmt, clippy `-D warnings`, nextest, doc, deny), `python-plugin` (ruff, ruff-format check, mypy --strict, pytest), `walking-skeleton` (depends on the first two; runs the e2e script).
@@ -63,7 +63,7 @@ The Loom federation axiom in `docs/suite/loom.md` (especially §3–§5) is **lo
 
 Before proposing or accepting any change that adds a new dependency, "lightweight glue layer," shared registry, or cross-product mediator, run it against the §5 failure test (semantic / initialization / pipeline coupling). Centralisation creeps back in naturally; treat any "wouldn't it be easier if we just..." proposal as suspicious.
 
-Two named asterisks (Wardline→Filigree pipeline coupling via Clarion; Python plugin's `wardline.core.registry.REGISTRY` import) have written retirement conditions in `loom.md` §5. Both persist into v1.0 and retire post-release per the conditions named there. Do not add new asterisks without the same discipline.
+Two named asterisks were registered in `loom.md` §5 with written retirement conditions. **Asterisk 2** (Python plugin's `wardline.core.registry.REGISTRY` import) is **retired as of 1.3.0** — the plugin now reads Wardline's NG-25 descriptor instead (ADR-018 Revision 3). **Asterisk 1** (Wardline→Filigree pipeline coupling via Clarion) remains live until Wardline's native Filigree emitter ships. Do not add new asterisks without the same discipline.
 
 ## Documentation map
 
@@ -150,7 +150,7 @@ Open issues for the v1.0 known limitations and any post-release follow-ups live
   protection (timestamp + nonce window) is ADR-034 forward-work tracked for
   post-1.0 hardening.
 
-
+
 ## Filigree Issue Tracker
 
 `filigree` tracks tasks for this project. Data lives in `.filigree/`. Prefer
@@ -252,8 +252,8 @@ Errors return `{error: str, code: ErrorCode, details?: dict}`. Switch on
 `code`, not on message text. Codes: `VALIDATION`, `NOT_FOUND`, `CONFLICT`,
 `INVALID_TRANSITION`, `PERMISSION`, `NOT_INITIALIZED`, `IO`,
 `INVALID_API_URL`, `FILE_REGISTRY_DISPLACED`, `REGISTRY_UNAVAILABLE`,
-`CLARION_REGISTRY_VERSION_MISMATCH`, `CLARION_OUT_OF_SYNC`,
-`BRIEFING_BLOCKED`, `STOP_FAILED`, `SCHEMA_MISMATCH`, `INTERNAL`.
+`CLARION_REGISTRY_VERSION_MISMATCH`, `BRIEFING_BLOCKED`, `STOP_FAILED`,
+`SCHEMA_MISMATCH`, `INTERNAL`.
 
 On `INVALID_TRANSITION`, call `workflow_transition_list` (MCP) or
 `filigree transitions ` to see what the workflow allows from here.
diff --git a/README.md b/README.md
index dc4da584..d272b17d 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ and trust-topology tools.
 
 ## Status
 
-**v1.2.0 — current release line.** Scope:
+**v1.3.0 — current release line.** Scope:
 
 - **Python only.** Other-language plugins (`NG-15`) are v2.0+ scope.
 - **Structural extraction + on-demand LLM summarisation.** `clarion analyze`
@@ -23,8 +23,14 @@ and trust-topology tools.
   egress is the LLM provider during `summary` calls.
 - **Stable identity and suite enrichment.** Clarion mints Stable Entity
   Identity (SEI) tokens, serves the federation HTTP read API, emits opted-in
-  Filigree scan findings, and enriches MCP reads with Filigree/Wardline context
-  without making sibling products mandatory.
+  Filigree scan findings (issue lookups now key by SEI), and enriches MCP reads
+  with Filigree/Wardline context without making sibling products mandatory.
+- **Wardline trust vocabulary via on-disk descriptor.** The Python plugin reads
+  Wardline's NG-25 trust-vocabulary descriptor as a plain file and tags
+  trust-decorated entities (`wardline:*`) — without importing Wardline, so a
+  co-installed Wardline is not required. Degrades cleanly when the descriptor is
+  absent. (Retires the last Clarion-side federation asterisk; see
+  [`docs/suite/loom.md`](docs/suite/loom.md) §5.)
 - **Guidance authoring.** Operators can author, import, export, and review
   guidance sheets through `clarion guidance`; consult agents consume them
   through MCP and summary cache invalidation.
@@ -56,13 +62,13 @@ instead of grep-and-read. The core tool families are:
 
 ```bash
 # 1. Install from the current GitHub Release
-TAG=v1.2.0
+TAG=v1.3.0
 curl -L -o clarion-x86_64-unknown-linux-gnu.tar.gz \
   "https://github.com/tachyon-beep/clarion/releases/download/${TAG}/clarion-x86_64-unknown-linux-gnu.tar.gz"
 tar xzf clarion-x86_64-unknown-linux-gnu.tar.gz
 install clarion-x86_64-unknown-linux-gnu/clarion ~/.local/bin/
 pipx install \
-  "https://github.com/tachyon-beep/clarion/releases/download/${TAG}/clarion-plugin-python-1.2.0.tar.gz"
+  "https://github.com/tachyon-beep/clarion/releases/download/${TAG}/clarion-plugin-python-1.3.0.tar.gz"
 
 # 2. Initialise a project
 cd /path/to/your/python/repo
diff --git a/docs/operator/getting-started.md b/docs/operator/getting-started.md
index 0c485880..9fc0422d 100644
--- a/docs/operator/getting-started.md
+++ b/docs/operator/getting-started.md
@@ -48,14 +48,14 @@ for the language plugin via GitHub Releases (per
 fallback below only when testing unreleased commits.
 
 ```bash
-TAG=v1.2.0
+TAG=v1.3.0
 curl -L -o clarion-x86_64-unknown-linux-gnu.tar.gz \
   "https://github.com/tachyon-beep/clarion/releases/download/${TAG}/clarion-x86_64-unknown-linux-gnu.tar.gz"
 tar xzf clarion-x86_64-unknown-linux-gnu.tar.gz
 install clarion-x86_64-unknown-linux-gnu/clarion ~/.local/bin/
 
 pipx install \
-  "https://github.com/tachyon-beep/clarion/releases/download/${TAG}/clarion-plugin-python-1.2.0.tar.gz"
+  "https://github.com/tachyon-beep/clarion/releases/download/${TAG}/clarion-plugin-python-1.3.0.tar.gz"
 ```
 
 Source-install fallback:

From 4660c1b194217540a6f3cdfb6a071eff992af3a2 Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 04:56:12 +1000
Subject: [PATCH 10/21] chore: untrack filigree-regenerated docs to stop diff
 churn

A running filigree process rewrites managed instruction blocks in these
files every session, producing constant diff noise. Untrack them (kept on
disk) and ignore them so the regeneration no longer churns the repo:

- CLAUDE.md
- AGENTS.md
- .agents/skills/filigree-workflow/SKILL.md

Current contents remain in history at 0a5c9d1. CLAUDE.md/AGENTS.md also
carry hand-authored doctrine; future hand-edits will be untracked, and a
clone relies on filigree to regenerate them.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 .agents/skills/filigree-workflow/SKILL.md | 325 --------------------
 .gitignore                                |   3 +
 AGENTS.md                                 | 351 ----------------------
 CLAUDE.md                                 | 270 -----------------
 4 files changed, 3 insertions(+), 946 deletions(-)
 delete mode 100644 .agents/skills/filigree-workflow/SKILL.md
 delete mode 100644 AGENTS.md
 delete mode 100644 CLAUDE.md

diff --git a/.agents/skills/filigree-workflow/SKILL.md b/.agents/skills/filigree-workflow/SKILL.md
deleted file mode 100644
index 76e81e40..00000000
--- a/.agents/skills/filigree-workflow/SKILL.md
+++ /dev/null
@@ -1,325 +0,0 @@
----
-name: filigree-workflow
-description: >
-  This skill should be used when the user asks to "track work", "create an issue",
-  "find something to work on", "what should I work on next", "triage bugs", "close
-  an issue", "check what's blocked", "plan a milestone", "review sprint progress",
-  "coordinate agents", or when working in a project that uses filigree for issue
-  tracking. Provides workflow patterns, team coordination protocols, and operational
-  guidance for the filigree issue tracker.
----
-
-# Filigree Workflow
-
-Filigree is an agent-native issue tracker that stores data locally in `.filigree/`.
-This skill provides procedural knowledge for using filigree effectively — as a solo
-agent or in a multi-agent swarm.
-
-## Core Workflow
-
-Every task follows this lifecycle:
-
-```
-filigree ready                                      → find available work (no blockers)
-filigree show                             → read requirements and context
-filigree transitions                      → check valid status transitions
-filigree start-work  --assignee     → atomically claim + transition into its working status
-[do the work, commit code]
-filigree close  --reason="summary of what was done"
-```
-
-Or skip steps 1–3 entirely with `filigree start-next-work --assignee ` to grab the highest-priority **startable** issue.
-
-> **Ready ≠ startable.** The working status is type-specific (tasks →
-> `in_progress`, features → `building`). Bugs start at `triage`, which has no
-> single-hop transition into work — they walk `triage → confirmed → fixing`. So
-> a triage bug is *ready* but not directly *startable*: `start-work` on one
-> returns `INVALID_TRANSITION` naming the next status to move through, and
-> `start-next-work` skips it. `ready` items carry a `startable` flag (and a
-> `next_action` hint when false). Pass `--advance` to either command to walk the
-> soft transitions automatically (`triage → confirmed → fixing`) instead of
-> being blocked or skipped.
-
-Always close with a `--reason` — it becomes audit trail for the next agent.
-
-## Priority Semantics
-
-| Priority | Meaning | Action |
-|----------|---------|--------|
-| P0 | Critical | Drop everything. Production is broken. |
-| P1 | High | Do next. Current sprint must-have. |
-| P2 | Medium | Default. Normal backlog work. |
-| P3 | Low | Nice to have. Do when P1/P2 are clear. |
-| P4 | Backlog | Someday. Don't schedule unless promoted. |
-
-When triaging, use `filigree batch-update  --priority=N` for bulk changes.
-
-## Starting Work
-
-### Solo or Swarm — Same Tool
-
-Use `start-work` (or `start-next-work`) for the usual case. Both atomically
-claim the issue *and* transition it into its working status in one DB
-transaction — optimistic-locking on the assignee, so concurrent callers can't
-both think they own the issue. The working status is type-specific (tasks →
-`in_progress`, features → `building`, bugs → `fixing`).
-
-```bash
-filigree start-work  --assignee               # specific issue
-filigree start-next-work --assignee                     # highest-priority startable
-filigree start-work  --assignee  --advance      # walk triage → confirmed → fixing
-```
-
-If another agent already owns the claim, the call fails with `code: CONFLICT`
-(CLI exit 4). Safe to retry against a different issue.
-
-`start-work` on a `triage` bug (or any type with no single-hop working status)
-returns `INVALID_TRANSITION` naming the intermediate status to move through
-first; `start-next-work` skips such issues. Pass `--advance` to walk the soft
-transitions to the nearest working status automatically (missing required
-fields become warnings, not blocks; hard edges are never auto-walked).
-
-### Niche: Claim Without Transitioning
-
-`claim` and `claim-next` still exist for the rare case where you want to
-reserve an issue but not advance its status (e.g. a coordinator earmarking
-work for a worker that will pick it up later). Prefer `start-work` for
-normal flow.
-
-```bash
-filigree claim  --assignee      # reserve only, no transition
-filigree claim-next --assignee 
-```
-
-## Key Commands
-
-### Finding Work
-
-```bash
-filigree ready                    # ready issues sorted by priority
-filigree list --status=open       # all open issues
-filigree search "auth"            # full-text search
-filigree critical-path            # longest dependency chain
-```
-
-### Creating Issues
-
-```bash
-filigree create "Title" --type=bug --priority=1
-filigree create "Title" --type=task -d "description" --dep 
-filigree create-plan --file plan.json   # milestone/phase/step hierarchy
-```
-
-### Managing Dependencies
-
-```bash
-filigree add-dep       # A depends on B
-filigree remove-dep  
-filigree blocked                          # show all blocked issues
-```
-
-### Context and Handoff
-
-```bash
-filigree add-comment  "what I found / what's left to do"
-filigree get-comments                 # read previous context
-filigree show                         # full details including deps
-```
-
-Always add a comment before closing or handing off — the next agent has no memory
-of the current conversation.
-
-## Workflow Patterns
-
-### Before Starting Work
-
-1. Run `filigree ready` to see available work
-2. Check `filigree critical-path` — unblocking the critical path has highest leverage
-3. Pick work that matches the current session's context (e.g., if code is already open)
-
-### When Finishing Work
-
-1. Add a comment summarising what was done and any follow-up needed
-2. Close with a reason: `filigree close  --reason="implemented X, tested Y"`
-3. Check if closing this issue unblocks anything: `filigree ready`
-
-### When Blocked
-
-1. Add a comment explaining the blocker
-2. Create the blocking issue if it doesn't exist
-3. Add the dependency: `filigree add-dep  `
-4. Move to other available work
-
-## Guidance Sheets
-
-For detailed patterns, consult these reference files:
-
-- **`references/workflow-patterns.md`** — Triage flows, sprint planning,
-  dependency management, bug lifecycle patterns
-- **`references/team-coordination.md`** — Multi-agent swarm protocols,
-  handoff conventions, claiming strategies, status update patterns
-- **`examples/sprint-plan.json`** — Complete create-plan input template
-  with cross-phase dependencies
-
-Load these when facing a specific workflow challenge rather than reading upfront.
-
-## File Records & Scan Findings
-
-The dashboard API tracks files and scan findings across the project. Use the
-schema discovery endpoint to find valid values and available endpoints:
-
-```
-GET /api/files/_schema
-```
-
-This returns valid severities, finding statuses, association types, sort fields,
-and a full endpoint catalog. When linking issues to files, use file associations:
-
-| Association Type | Meaning |
-|-----------------|---------|
-| `bug_in` | Bug reported in this file |
-| `task_for` | Task related to this file |
-| `scan_finding` | Automated scan finding |
-| `mentioned_in` | File referenced in issue |
-
-## Response Shapes (2.0)
-
-When parsing `--json` output or MCP responses, expect these unified envelopes:
-
-- **Batch ops** → `{succeeded: [...], failed: [{id, error, code}, ...], newly_unblocked?: [...]}`.
-  `failed` is always present (empty list if none); `newly_unblocked` is
-  present only when non-empty (omitted when the op unblocked nothing). Pass `--detail=full` (CLI) or
-  `response_detail="full"` (MCP) to get full records back.
-- **List ops** → `{items: [...], has_more: bool, next_offset?: int}`.
-  `next_offset` only appears when there is a next page.
-- **Errors** → `{error: str, code: ErrorCode, details?: dict}`. `code` is
-  one of: `VALIDATION`, `NOT_FOUND`, `CONFLICT`, `INVALID_TRANSITION`,
-  `PERMISSION`, `NOT_INITIALIZED`, `IO`, `INVALID_API_URL`,
-  `FILE_REGISTRY_DISPLACED`, `REGISTRY_UNAVAILABLE`,
-  `CLARION_REGISTRY_VERSION_MISMATCH`, `CLARION_OUT_OF_SYNC`,
-  `BRIEFING_BLOCKED`, `STOP_FAILED`, `SCHEMA_MISMATCH`, `INTERNAL`.
-  Branch on `code` for retry policy
-  (`CONFLICT` → exit 4, retryable; everything at exit 1 needs operator
-  intervention).
-
-The issue ID is always `issue_id` in 2.0 — in MCP inputs, response payloads,
-and CLI JSON. Status is always `status`; "state" was retired as a
-user-facing word.
-
-## Health and Diagnostics
-
-```bash
-filigree doctor           # check installation health
-filigree stats            # project-wide counts
-filigree metrics          # cycle time, lead time, throughput
-filigree events       # audit trail for a specific issue
-```
-
-## Observations — Ambient Note-Taking
-
-Observations are a scratchpad for things you notice *while doing other work*. They
-are not issues — they're lightweight, expiring notes that let you capture a thought
-without breaking flow.
-
-### When to Observe
-
-Observations are for **incidental** defects — things you notice *in passing*
-while working on something else, that fall *outside the scope of your current
-task*. The core use case is: "I don't have time to investigate this right now,
-but I want to come back to it."
-
-Examples of good observations:
-
-- A code smell in a neighbouring file you happened to read
-- A missing test for an edge case unrelated to what you're changing
-- A potential bug in a module you're not touching
-- A TODO or FIXME that looks stale
-- A dependency that might be outdated
-
-**Always include `file_path` and `line`** when the observation is about specific code.
-This anchors it for whoever triages it later.
-
-### When NOT to Observe
-
-**You fix bugs in your currently defined scope. You do NOT use observations to
-finish work prematurely.**
-
-If you're working on task X and you notice that your implementation of X has a
-gap, a missed edge case, an untested branch, a known shortcoming, or a piece of
-follow-up that "should really be done too" — that is **task scope, not an
-observation**. You own it. Handle it one of these ways instead:
-
-- **Fix it now** as part of the current task. (Default.)
-- **Expand the task** (or split a sub-task) and address it in this work stream.
-- **File a proper issue** with a dependency on the current task, so the gap is
-  visible in the work record before you close.
-- **Surface it to the user** if it changes the shape of what you're delivering.
-
-Filing your own task's deficiencies as observations and closing the task is
-**not** completing the task. It is shipping known-broken work and hiding the
-debt in a 14-day expiring scratchpad — where it will quietly rot, get
-auto-dismissed, and never be addressed. The work record must reflect what is
-actually outstanding.
-
-**The test:** *"Would I have noticed this even if I weren't working on this
-task?"* If yes → observation. If no → it's part of the work, fix it.
-
-**Don't observe things that are clearly issues either.** If you're confident
-something is a bug or a needed feature, create an issue directly. Observations
-are for "hmm, this might be worth looking at" — the uncertain middle ground.
-
-### Triage Workflow
-
-Observations expire after 14 days. Triage them before they rot:
-
-1. **At session end:** run `observation_list` and quickly scan what's accumulated
-2. **For each observation, decide:**
-   - **Dismiss** — not actionable, already fixed, or not worth tracking. Use
-     `observation_dismiss` with a brief reason for the audit trail.
-   - **Promote** — deserves to be tracked as an issue. Use `observation_promote`
-     which atomically creates an issue and labels it `from-observation`. Choose
-     the right issue type:
-     - `type='bug'` — something is broken or produces wrong results
-     - `type='task'` (default) — cleanup, improvement, or "this works but is shitty"
-     - `type='feature'` — a missing capability that should exist
-     - `type='requirement'` — a formal requirement to be reviewed, approved, and verified, when the requirements pack is enabled
-   - **Leave it** — still uncertain. Let it age. If it survives a few sessions
-     without being promoted, it's probably a dismiss.
-
-3. **Batch cleanup:** use the MCP tool `observation_batch_dismiss` when several observations
-   have gone stale together.
-
-### Promote vs Dismiss
-
-| Signal | Action |
-|--------|--------|
-| You noticed it twice in separate sessions | Promote |
-| It's in a hot code path or critical module | Promote |
-| It has a clear fix or next step | Promote |
-| It was about code that's since been refactored | Dismiss |
-| It's a style/taste preference, not a defect | Dismiss |
-| You can't articulate what the fix would be | Leave it (or dismiss if > 7 days old) |
-
-### Tracking the Pipeline
-
-Promoted observations get the `from-observation` label. To see the pipeline output:
-
-```bash
-filigree list --label=from-observation     # All promoted observations
-filigree search "from-observation"         # Search with context
-```
-
-## Quick Decision Guide
-
-| Situation | Action |
-|-----------|--------|
-| "What should I work on?" | `filigree ready`, pick highest priority |
-| "Is this blocked?" | `filigree show `, check blocked_by |
-| "Multiple agents need work" | `filigree start-next-work --assignee ` |
-| "I found a new bug" | `filigree create "..." --type=bug --priority=1` |
-| "This task is bigger than expected" | Create sub-tasks, add deps |
-| "I'm done" | Comment, close with reason, check `ready` |
-| "Something changed while I worked" | `filigree changes --since ` |
-| "I noticed something odd in a file I'm passing through" | `observation_create` with file_path and line — keep working |
-| "I noticed a gap in the work I'm currently doing" | Fix it, expand the task, or file a proper issue — **do not** observe it |
-| "These observations are piling up" | `observation_list`, then dismiss or promote each |
diff --git a/.gitignore b/.gitignore
index aa9d234c..de27e2c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,6 @@ tests/e2e/external-operator-smoke-results-*.md
 # Documentation site build output (mkdocs `site_dir`, web/mkdocs.yml).
 /site-build/
 .clarion/clarion.lock
+AGENTS.md
+CLAUDE.md
+.agents/skills/filigree-workflow/SKILL.md
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index 1c2276dd..00000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,351 +0,0 @@
-# AGENTS.md
-
-This file is the operating contract for coding agents working in this
-repository. It is intentionally practical: start here, then follow the linked
-source-of-truth docs when the task touches design, release, or federation
-semantics.
-
-## First Principles
-
-Clarion is a local-first code-archaeology tool. It ingests a repository,
-extracts entities and relationships, stores the graph in SQLite, and serves
-that graph to consult-mode agents over MCP and the federation HTTP read API.
-The v1.0 product is a Rust workspace plus a Python language plugin.
-
-Clarion is part of the Loom suite with Filigree and Wardline. The governing
-doctrine is the Loom federation rule:
-
-1. Each product must remain useful alone.
-2. Each pair of products must compose meaningfully.
-3. Integration must enrich a product's view, not become required for that
-   product's semantics to make sense.
-
-Before accepting any design that adds a shared runtime, shared registry,
-cross-product mediator, mandatory sibling dependency, or "small glue layer,"
-read [docs/suite/loom.md](docs/suite/loom.md) and apply its failure test.
-Centralization is usually the thing trying to sneak in wearing a helpful hat.
-
-## Start Every Session
-
-Run a live project-state check before substantive work:
-
-```bash
-filigree session-context
-git status --short --branch
-```
-
-Use the tracker state, current branch, and dirty tree you actually see. Do not
-assume a previous handoff, memory entry, or branch name is current.
-
-If the tree is dirty, treat existing changes as user work unless you made them
-in this session. Read affected files before editing near them, and do not
-revert unrelated changes.
-
-## Source Of Truth
-
-When documents disagree, use this precedence:
-
-1. Accepted ADRs under [docs/clarion/adr/](docs/clarion/adr/).
-2. [docs/clarion/1.0/requirements.md](docs/clarion/1.0/requirements.md).
-3. [docs/clarion/1.0/system-design.md](docs/clarion/1.0/system-design.md).
-4. [docs/clarion/1.0/detailed-design.md](docs/clarion/1.0/detailed-design.md).
-5. Implementation and review history under [docs/implementation/](docs/implementation/).
-
-ADRs are decision records, not suggestions. Accepted ADRs are immutable except
-for status changes and supersession links. If an accepted ADR is wrong, write or
-propose a successor ADR rather than silently rewriting the old decision.
-
-Requirement IDs, ADR IDs, and federation contract names are load-bearing. Keep
-them stable and cite them in Filigree issues, commits, and review notes when
-they explain the work.
-
-## Reading Map
-
-Use the smallest set that grounds the task:
-
-- New orientation: [README.md](README.md), then
-  [docs/suite/briefing.md](docs/suite/briefing.md), then
-  [docs/suite/loom.md](docs/suite/loom.md).
-- Product design: [docs/clarion/1.0/README.md](docs/clarion/1.0/README.md),
-  then requirements, system design, and detailed design in that order.
-- Federation work: [docs/federation/contracts.md](docs/federation/contracts.md)
-  and the fixtures under [docs/federation/fixtures/](docs/federation/fixtures/).
-- Release work:
-  [docs/operator/v1.0-release-governance.md](docs/operator/v1.0-release-governance.md),
-  [.github/workflows/ci.yml](.github/workflows/ci.yml), and
-  [.github/workflows/release.yml](.github/workflows/release.yml).
-- Historical sprint context:
-  [docs/implementation/README.md](docs/implementation/README.md). Treat this
-  as supporting context, not as a normative source.
-
-## Repository Shape
-
-- [Cargo.toml](Cargo.toml) defines the Rust 2024 workspace and shared
-  dependency/lint policy.
-- [crates/clarion-core/](crates/clarion-core/) owns entity IDs, plugin hosting,
-  manifests, process limits, discovery, and core protocols.
-- [crates/clarion-storage/](crates/clarion-storage/) owns SQLite storage,
-  writer-actor behavior, and reader-pool access.
-- [crates/clarion-cli/](crates/clarion-cli/) owns the `clarion` binary and user
-  commands such as `install`, `analyze`, and `serve`.
-- [crates/clarion-mcp/](crates/clarion-mcp/) owns the MCP consult surface.
-- [crates/clarion-scanner/](crates/clarion-scanner/) owns pre-ingest secret
-  scanning.
-- [crates/clarion-plugin-fixture/](crates/clarion-plugin-fixture/) is a test
-  fixture crate.
-- [plugins/python/](plugins/python/) is the v1.0 Python language plugin.
-
-Prefer existing boundaries. If a change crosses crate, plugin, CLI, MCP,
-storage, or federation boundaries, name the contract being changed and add a
-test at the boundary.
-
-## Engineering Rules
-
-Do not guess. Read the source, reproduce behavior when useful, and make claims
-only as strong as the evidence you have.
-
-Prefer the earliest boundary that can enforce an invariant. Configuration,
-manifest parsing, protocol decoding, storage writes, and public response types
-are better places for hard failures than downstream call sites.
-
-For bug fixes, add or identify a focused failing regression before the fix when
-the behavior is testable. Keep fixes small enough that the regression proves the
-point.
-
-Use structured parsers and APIs where the repo already has them. Avoid ad hoc
-string surgery for TOML, JSON, YAML, SQL, or protocol payloads unless the local
-code already uses that pattern for the same reason.
-
-Do not introduce new cross-product coupling to make an implementation easier.
-If a sibling integration becomes necessary for semantics, stop and reconcile the
-design with the Loom doctrine before coding.
-
-Use focused subagents when they materially improve confidence or throughput.
-For release reviews, broad audits, multi-surface debugging, and independent
-implementation slices, split the work by boundary and dispatch subagents without
-asking for another permission round. Keep each subagent prompt self-contained,
-give it a narrow scope, avoid overlapping write sets, and integrate its findings
-against the live tree before reporting or closing work.
-
-## Verification Gates
-
-ADR-023 sets the CI floor. Run the narrowest useful gate while iterating, then
-run the relevant full gate before claiming completion.
-
-Rust gates:
-
-```bash
-cargo fmt --all -- --check
-cargo clippy --workspace --all-targets --all-features -- -D warnings
-cargo build --workspace --bins
-cargo nextest run --workspace --all-features
-RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features
-cargo deny check
-```
-
-Python plugin gates, from the repo root:
-
-```bash
-plugins/python/.venv/bin/ruff check plugins/python
-plugins/python/.venv/bin/ruff format --check plugins/python
-plugins/python/.venv/bin/mypy --strict plugins/python
-plugins/python/.venv/bin/pytest plugins/python
-```
-
-End-to-end gates:
-
-```bash
-bash tests/e2e/sprint_1_walking_skeleton.sh
-bash tests/e2e/sprint_2_mcp_surface.sh
-bash tests/e2e/phase3_subsystems.sh
-```
-
-For release work, also run the governance guard described in
-[docs/operator/v1.0-release-governance.md](docs/operator/v1.0-release-governance.md).
-Live GitHub checks need a token with repository administration and Actions
-policy read access.
-
-## Filigree Workflow
-
-Filigree is the project tracker. Prefer MCP tools when available; use the CLI
-otherwise. Start from live state:
-
-```bash
-filigree session-context
-```
-
-Use atomic claim-and-transition verbs:
-
-```bash
-filigree start-next-work --assignee 
-filigree start-work  --assignee 
-```
-
-Do not chain `filigree claim` with a later status update. The combined verbs
-exist to avoid racing other agents.
-
-Close tracker work only after the implementation, verification, and comments
-match reality. If a discovered defect belongs to the active task, own it: fix
-it, broaden the task, file a proper dependent issue, or raise the blocker. Do
-not hide in-scope work in an expiring observation.
-
-Use observations only for incidental findings outside the current task. At
-session end, check accumulated observations and either promote or dismiss what
-is ready to triage.
-
-## Release Work
-
-Clarion v1.0 publishes from GitHub Releases. Do not cut tags or treat a commit
-as release-ready until:
-
-- Filigree shows no unresolved release blockers.
-- The full CI floor has passed on the release commit or PR.
-- The GitHub release governance guard passes for `main`.
-- The release workflow dry run from `main` passes.
-- The public artifact smoke test has been run from the GitHub Release artifacts.
-
-Release notes and status summaries must distinguish local commits, pushed
-branches, open PRs, merged PRs, and published tags. A local green branch is not
-a shipped release.
-
-## Git And Commit Hygiene
-
-Keep commits scoped to the work requested. Do not stage unrelated dirty files.
-If the user asks for a broad commit, re-check `git status --short` immediately
-before staging so the scope is explicit.
-
-Prefer normal, reviewable history. Do not force-push, reset hard, delete
-branches, or discard work unless the user explicitly asks and the destructive
-scope is clear.
-
-If hooks fail, fix the blocker rather than bypassing hooks unless the user
-explicitly authorizes a checkpoint with failing gates.
-
-## Communication
-
-Keep status updates short and concrete: what you checked, what changed, what is
-blocked, and which verification ran. When reporting failures, include the exact
-command and the first useful error, not a vague "tests failed."
-
-When you are unsure, say what evidence is missing and go get it if it is cheap.
-When evidence is expensive or requires credentials, explain the limitation and
-the next command a maintainer can run.
-
-
-## Filigree Issue Tracker
-
-`filigree` tracks tasks for this project. Data lives in `.filigree/`. Prefer
-the MCP tools (`mcp__filigree__*`) when available; fall back to the `filigree`
-CLI otherwise.
-
-### Workflow
-
-```bash
-# At session start
-filigree session-context                            # ready / in-progress / critical path
-
-# Pick up the next startable issue (atomic claim + transition into its working status)
-filigree start-next-work --assignee 
-# ...or claim a specific issue
-filigree start-work  --assignee 
-
-# Do the work, commit, then
-filigree close 
-```
-
-Use the atomic claim+transition verbs — `work_start` / `work_start_next`
-(MCP) or `start-work` / `start-next-work` (CLI). Do **not** chain
-`work_claim` (MCP) or `filigree claim` (CLI) with a subsequent status
-update — the two-step form races against other agents; the combined verb is
-atomic.
-
-**Ready ≠ startable.** The working status is type-specific (tasks →
-`in_progress`, features → `building`). Bugs start at `triage`, which has no
-single-hop transition into work (`triage → confirmed → fixing`), so a triage
-bug is *ready* but not directly *startable*: `work_start` on one returns
-`INVALID_TRANSITION` naming the next status, and `work_start_next` skips it.
-`work_ready` items carry a `startable` flag (plus a `next_action` hint when
-false). Pass `advance=true` (MCP) / `--advance` (CLI) to walk the soft
-transitions to the nearest working status automatically.
-
-### Observations: when (and when not) to use them
-
-`observation_create` is a fire-and-forget scratchpad for *incidental* defects — things
-you notice *outside the scope of your current task* (a code smell in a
-neighbouring file, a stale TODO, a missing test for an edge case you happened
-to spot). Notes expire after 14 days unless promoted. Include `file_path` and
-`line` when relevant. At session end, skim `observation_list` and either
-`observation_dismiss` or `observation_promote` for what has accumulated.
-
-**You fix bugs in your currently defined scope. You do NOT use observations
-to finish work prematurely.** If a defect, gap, or follow-up belongs to your
-current task, you own it — handle it as part of that task: fix it now, expand
-the task's scope, file a proper issue with a dependency, or surface it to the
-user. Filing it as an observation and closing the task is *not* completing
-the task; it is shipping known-broken work and hiding the debt in a 14-day
-expiring scratchpad. The test is "would I have noticed this even if I weren't
-working on this task?" If no, it's task scope, not an observation.
-
-### Priority scale
-
-- P0: Critical (drop everything)
-- P1: High (do next)
-- P2: Medium (default)
-- P3: Low
-- P4: Backlog
-
-### Reaching for tools
-
-MCP tool schemas describe each tool; `filigree --help` and `filigree 
---help` are the authoritative CLI reference. You do not need to memorise
-either catalogue. The verbs you will reach for most:
-
-- **Find work:** `work_ready`, `work_blocked`, `issue_list`, `issue_search`
-- **Claim work:** `work_start`, `work_start_next`
-- **Update:** `comment_add`, `label_add`, `issue_update`, `issue_close`
-- **Admin (irreversible):** `issue_delete` (MCP) / `delete-issue` (CLI) —
-  hard-deletes a terminal issue and its rows; `admin_undo_last` cannot reverse it.
-- **Scratchpad:** `observation_create`, `observation_list`, `observation_promote`, `observation_dismiss`
-- **Cross-product entity bindings (ADR-029):** `entity_association_add`,
-  `entity_association_remove`, `entity_association_list`,
-  `entity_association_list_by_entity`. Used when a sibling tool (e.g.
-  Clarion) needs to bind a Filigree issue to a function, class, or
-  module identifier it owns. The `entity_id` is an opaque string
-  from Filigree's perspective; the consumer (the sibling tool's read
-  path) does drift detection against the stored
-  `content_hash_at_attach`. `entity_association_list_by_entity` is the
-  reverse-lookup surface — given a Clarion entity ID, return every
-  Filigree issue bound to it (project isolation is by DB file). Also
-  reachable over HTTP as
-  `GET/POST /api/issue/{issue_id}/entity-associations`,
-  `DELETE /api/issue/{issue_id}/entity-associations?entity_id=…`,
-  and `GET /api/entity-associations?entity_id=…`.
-- **Health:** `stats_get`, `metrics_get`, `mcp_status_get`
-
-Pass `--actor ` (CLI) so events attribute to your agent identity. It
-works in either position — before the verb (`filigree --actor X update …`) or
-after it (`filigree update … --actor X`); the post-verb value overrides the
-group-level one.
-
-### Error handling
-
-Errors return `{error: str, code: ErrorCode, details?: dict}`. Switch on
-`code`, not on message text. Codes: `VALIDATION`, `NOT_FOUND`, `CONFLICT`,
-`INVALID_TRANSITION`, `PERMISSION`, `NOT_INITIALIZED`, `IO`,
-`INVALID_API_URL`, `FILE_REGISTRY_DISPLACED`, `REGISTRY_UNAVAILABLE`,
-`CLARION_REGISTRY_VERSION_MISMATCH`, `CLARION_OUT_OF_SYNC`,
-`BRIEFING_BLOCKED`, `STOP_FAILED`, `SCHEMA_MISMATCH`, `INTERNAL`.
-
-On `INVALID_TRANSITION`, call `workflow_transition_list` (MCP) or
-`filigree transitions ` to see what the workflow allows from here.
-
-Two failure modes deserve a specific response:
-
-- **`SCHEMA_MISMATCH`** — the installed `filigree` is older than the project
-  database. The error message contains upgrade guidance. Surface it to the
-  user; do not retry.
-- **`ForeignDatabaseError`** — filigree found a parent project's database
-  but no local `.filigree.conf`. Run `filigree init` in the current
-  directory. Do **not** `cd` upward to a different project unless that was
-  the actual intent.
-
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 678f812c..00000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,270 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Repository state
-
-**v1.3.0 — current pre-release working version.** The `v1.0.0` (first publishable release), `v1.0.1`, and `v1.1.0` tags are cut; pre-release working tags `v0.1-sprint-1` and `v0.1-sprint-2` remain in the repo as historical anchors. Workspace + Python plugin are at 1.3.0 (the 1.2.0 line is changelog-cut but not yet git-tagged). 1.3.0 adds descriptor-based Wardline trust-vocabulary consumption — the Python plugin reads Wardline's NG-25 descriptor instead of importing `wardline.core.registry`, fully retiring `loom.md` §5 asterisk 2 (ADR-018 Revision 3). ADR-014 federation HTTP read API ships with bearer auth, batch resolution, briefing-blocked propagation, and stable per-project `instance_id`. See [`CHANGELOG.md`](CHANGELOG.md) for the full scope and [`docs/implementation/`](docs/implementation/) for sprint-closure artifacts.
-
-### Layout (post-1.0)
-
-- **Rust workspace** at repo root (`Cargo.toml`, `crates/`) — six crates:
-  - `crates/clarion-core/` — entity-ID assembler, plugin host (`plugin/host.rs`), JSON-RPC transport, manifest parser, jail + limits, discovery, breaker.
-  - `crates/clarion-storage/` — writer-actor + reader-pool over SQLite (per ADR-011).
-  - `crates/clarion-cli/` — the `clarion` binary; `install` and `analyze` subcommands.
-  - `crates/clarion-mcp/` — the MCP protocol surface (the `clarion serve` tool catalogue, Filigree HTTP client, scan-results emission, snapshot reader).
-  - `crates/clarion-scanner/` — core-owned pre-ingest secret scanner; stores only positions, rule IDs, and a detect-secrets-compatible SHA-1 digest (literal secret values never leave the scan).
-  - `crates/clarion-plugin-fixture/` — test-only fixture plugin used by `wp2_e2e` integration tests.
-- **Python plugin** at `plugins/python/` (editable install: `pip install -e plugins/python[dev]`). Speaks the L4 JSON-RPC protocol; emits function entities with L7 qualnames; reads Wardline's NG-25 trust-vocabulary descriptor (`wardline_descriptor.py`) to tag trust-decorated entities, without importing Wardline.
-- **Shared cross-language fixture** at `fixtures/entity_id.json` — the L2 byte-for-byte parity proof (consumed by Rust + Python tests both).
-- **End-to-end test** at `tests/e2e/sprint_1_walking_skeleton.sh` — runs the README §3 demo verbatim and asserts the sqlite output.
-- **CI** at `.github/workflows/ci.yml` — three jobs: `rust` (fmt, clippy `-D warnings`, nextest, doc, deny), `python-plugin` (ruff, ruff-format check, mypy --strict, pytest), `walking-skeleton` (depends on the first two; runs the e2e script).
-
-### Build / test commands
-
-ADR-023 names these as the floor — every PR must pass all of them.
-
-```bash
-# Rust gates
-cargo fmt --all -- --check
-cargo clippy --workspace --all-targets --all-features -- -D warnings
-cargo build --workspace --bins        # wp2_e2e tests need clarion-plugin-fixture on disk
-cargo nextest run --workspace --all-features
-RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features
-cargo deny check
-
-# Python gates (run from repo root)
-plugins/python/.venv/bin/ruff check plugins/python
-plugins/python/.venv/bin/ruff format --check plugins/python
-plugins/python/.venv/bin/mypy --strict plugins/python
-plugins/python/.venv/bin/pytest plugins/python
-
-# End-to-end
-bash tests/e2e/sprint_1_walking_skeleton.sh
-```
-
-Pre-commit hooks at `.pre-commit-config.yaml` (repo root) wire ruff + ruff-format + mypy on every `git commit`. Install with `plugins/python/.venv/bin/pre-commit install`.
-
-The Sprint-1 demo script in `docs/implementation/sprint-1/README.md` §3 is the canonical first-build recipe and is verified in CI by the `walking-skeleton` job.
-
-## What Clarion is, in one paragraph
-
-Clarion is a code-archaeology tool: it ingests a codebase, extracts entities (functions, classes, modules), clusters them into subsystems, and serves structured briefings to consult-mode LLM agents over MCP so those agents do not have to re-explore the tree on every question. Single-binary Rust core + language plugins (Python first); SQLite-backed local state under `.clarion/`; designed for "enterprise rigor at lack of scale." Target first customer is `elspeth` (~425k LOC Python).
-
-Clarion is one of three (soon four) products in the **Loom** suite. The other repos — `filigree` and `wardline` — are not vendored here but are owned by the same author and are referenced extensively. Cross-product work in WP9/WP10/Sprint-2+ is within-scope, not external.
-
-## Doctrine you must read before changing design docs
-
-The Loom federation axiom in `docs/suite/loom.md` (especially §3–§5) is **load-bearing for every architectural decision in this repo**. The three rules:
-
-1. Each product is solo-useful.
-2. Each pair composes meaningfully on its own.
-3. Integration is enrich-only — a sibling may add information to another product's view but must never be required for that product's semantics to make sense.
-
-Before proposing or accepting any change that adds a new dependency, "lightweight glue layer," shared registry, or cross-product mediator, run it against the §5 failure test (semantic / initialization / pipeline coupling). Centralisation creeps back in naturally; treat any "wouldn't it be easier if we just..." proposal as suspicious.
-
-Two named asterisks were registered in `loom.md` §5 with written retirement conditions. **Asterisk 2** (Python plugin's `wardline.core.registry.REGISTRY` import) is **retired as of 1.3.0** — the plugin now reads Wardline's NG-25 descriptor instead (ADR-018 Revision 3). **Asterisk 1** (Wardline→Filigree pipeline coupling via Clarion) remains live until Wardline's native Filigree emitter ships. Do not add new asterisks without the same discipline.
-
-## Documentation map
-
-```
-docs/
-├── suite/                         Loom-wide doctrine (read-first for new contributors)
-│   ├── briefing.md                5-minute introduction
-│   └── loom.md                    Founding doctrine, federation axiom, go/no-go test
-├── clarion/
-│   ├── 1.0/                       Canonical product docset for the 1.0 release
-│   │   ├── README.md              Reading-order map for the design ladder
-│   │   ├── requirements.md        The WHAT — REQ-/NFR-/CON-/NG- IDs, baselined
-│   │   ├── system-design.md       The HOW — architecture, mechanisms, §2–§11 with `Addresses:` headers
-│   │   └── detailed-design.md     Implementation reference — schemas, rule catalogs, appendices
-│   └── adr/                       Authored architecture decision records (ADR-001 … ADR-031)
-├── federation/                    Cross-product wire contracts + normative fixtures
-│   ├── contracts.md               Pinned HTTP read API + auth + path-normalization
-│   └── fixtures/                  Normative request/response fixtures
-└── implementation/                Work-package sequencing (lives ABOVE the docset because WPs span siblings)
-    ├── v0.1-plan.md               11 WPs in dependency order, with anchoring docs/ADRs per WP
-    ├── sprint-1/                  Walking-skeleton sprint (WP1+WP2+WP3)
-    ├── sprint-2/                  B-track + scanner sprint
-    └── sprint-3/                  Loom federation hardening sprint (ADR-014)
-```
-
-### Reading order by intent
-
-- **New to the project**: `docs/suite/briefing.md` → `docs/suite/loom.md` → `docs/clarion/1.0/README.md`.
-- **Implementing**: `requirements.md` → `system-design.md` → `detailed-design.md` → relevant ADRs → the WP doc under `docs/implementation/`.
-- **Reviewing a design proposal**: read the requirement IDs it cites, then the system-design section listed in those requirements' `See` lines, then check whether any Accepted ADR already constrains the answer.
-
-## Where canonical truth lives
-
-When the same fact appears in multiple files, this is the precedence:
-
-1. **Accepted ADRs** in `docs/clarion/adr/` — the locked decisions. 28 are Accepted at 1.0 (ADR-001…ADR-007, ADR-011, ADR-013…ADR-018, ADR-021…ADR-034); four remain Backlog (ADR-009, ADR-010, ADR-019, ADR-020) and are tracked inside `system-design.md` §12 / `detailed-design.md` §11 until promoted. ADR-012 was superseded by ADR-014 (which is in turn partially extended by ADR-034 for the federation read-API security posture and error envelope).
-2. **`requirements.md`** — REQ-/NFR-/CON-/NG- IDs are stable and load-bearing (filigree issues and commit messages cite them by ID; never reuse a retired ID).
-3. **`system-design.md`** — `Addresses:` headers on each §2–§11 section define the requirement acceptance surface for that subsystem.
-4. **`detailed-design.md`** — exact schemas, rule catalogues, appendices.
-5. Reviews under `docs/clarion/1.0/reviews/` are supporting context only, not normative. Do not cite a review as the source of a current decision; cite the ADR or design doc that absorbed it.
-
-If `requirements.md` and `system-design.md` disagree, the requirement wins and the design doc is the bug. If an ADR exists, it overrides both.
-
-## Implementation work-package vocabulary
-
-Work is organised as numbered Work Packages (WP1–WP11) and grouped into sprints. Each WP doc has the same skeleton: scope, deliverables, exit criteria, anchoring system-design sections, ADRs satisfied, ADRs surfaced, unresolved questions.
-
-Sprint 1 commits a numbered set of "lock-ins" (L1–L9) — design surfaces that are cheap to change before the sprint closes and expensive after. When touching anything in `wp1-scaffold.md`, `wp2-plugin-host.md`, or `wp3-python-plugin.md`, check the lock-in table in `docs/implementation/sprint-1/README.md` §4 first; later sprints will read and write against those exact shapes.
-
-## Key terminology to use consistently
-
-- **Entity ID** (per ADR-003 + ADR-022): three colon-separated segments — `{plugin_id}:{kind}:{canonical_qualified_name}`, e.g. `python:function:auth.tokens.refresh`. The plugin owns segments 1 and 3; the core never invents kinds.
-- **Finding**: a unified record type for defects, structural observations, classifications, metrics, and suggestions — emitted by Clarion (and other Loom tools) into Filigree via `POST /api/v1/scan-results`. See ADR-004.
-- **Observation**: fire-and-forget agent note (see Filigree workflow). Distinct from a Finding.
-- **Guidance sheet**: institutional knowledge attached to an entity (Clarion-authored).
-- **Briefing**: structured per-entity summary that Clarion serves to consult-mode agents.
-- **Loom suite**: the federation. Refer to it as "the Loom suite" in docs (per project memory). Member products are Clarion, Filigree, Wardline, and the proposed Shuttle.
-
-Avoid: "Loom platform," "Loom runtime," "Loom broker," "Loom store" — Loom is a family name and a doctrine, not anything that runs (per `loom.md` §6).
-
-## Editorial conventions for design docs
-
-- ADR files are immutable once Accepted, except for status changes and "Superseded by ADR-NNN" links. To revise an Accepted ADR, write a new ADR that supersedes it.
-- Each requirement statement has: stable ID, plain-English statement, rationale, verification method, and a `See:` link to the addressing system-design section. Match the existing pattern when adding requirements.
-- When renaming or moving design files, prefer `git mv` over leaving redirect stubs behind. The user has explicitly rejected legacy-filename "history preservation" tech debt.
-
-## Task tracking
-
-`filigree` is the issue tracker for this project (config in `.filigree/`, MCP server registered in `.mcp.json`). The global `~/CLAUDE.md` file describes the workflow and CLI/MCP commands; do not duplicate that here. Project-specific notes:
-
-- Sprint 1 / Sprint 2 / Sprint 3 issues are all `delivered`/`closed` at 1.0. Post-1.0 issues should follow the same `release:1.0`-style label scheme using whatever release tag (`release:1.1`, `release:2.0`) the work targets.
-- Filigree issue bodies should cite `REQ-*` / `NFR-*` / ADR IDs verbatim — those IDs are how design docs and tracker stay linked.
-
-### Post-1.0 follow-up tracking
-
-Open issues for the v1.0 known limitations and any post-release follow-ups live in `filigree` under the `release:1.1` (and beyond) label. `filigree get-ready` / `filigree session-context` are authoritative for what's currently actionable. Notable themes:
-
-- **WP9-B (Filigree finding emission)** — deferred from 1.0 per the [Sprint 2 scope amendment](docs/implementation/sprint-2/scope-amendment-2026-05.md#4-v01-planmd-resequencing).
-- **HTTP file language manifest registry** — narrow core-extension fallback at 1.0; persistent registry is a post-1.0 task.
-- **HMAC inbound auth (C-4)** — HMAC (`X-Loom-Component: clarion:`) is the
-  preferred non-loopback authentication mechanism in v1.0 per ADR-034,
-  configured via `serve.http.identity_token_env`. The legacy bearer-token path
-  (`serve.http.token_env`) remains supported for compatibility. Replay
-  protection (timestamp + nonce window) is ADR-034 forward-work tracked for
-  post-1.0 hardening.
-
-
-## Filigree Issue Tracker
-
-`filigree` tracks tasks for this project. Data lives in `.filigree/`. Prefer
-the MCP tools (`mcp__filigree__*`) when available; fall back to the `filigree`
-CLI otherwise.
-
-### Workflow
-
-```bash
-# At session start
-filigree session-context                            # ready / in-progress / critical path
-
-# Pick up the next startable issue (atomic claim + transition into its working status)
-filigree start-next-work --assignee 
-# ...or claim a specific issue
-filigree start-work  --assignee 
-
-# Do the work, commit, then
-filigree close 
-```
-
-Use the atomic claim+transition verbs — `work_start` / `work_start_next`
-(MCP) or `start-work` / `start-next-work` (CLI). Do **not** chain
-`work_claim` (MCP) or `filigree claim` (CLI) with a subsequent status
-update — the two-step form races against other agents; the combined verb is
-atomic.
-
-**Ready ≠ startable.** The working status is type-specific (tasks →
-`in_progress`, features → `building`). Bugs start at `triage`, which has no
-single-hop transition into work (`triage → confirmed → fixing`), so a triage
-bug is *ready* but not directly *startable*: `work_start` on one returns
-`INVALID_TRANSITION` naming the next status, and `work_start_next` skips it.
-`work_ready` items carry a `startable` flag (plus a `next_action` hint when
-false). Pass `advance=true` (MCP) / `--advance` (CLI) to walk the soft
-transitions to the nearest working status automatically.
-
-### Observations: when (and when not) to use them
-
-`observation_create` is a fire-and-forget scratchpad for *incidental* defects — things
-you notice *outside the scope of your current task* (a code smell in a
-neighbouring file, a stale TODO, a missing test for an edge case you happened
-to spot). Notes expire after 14 days unless promoted. Include `file_path` and
-`line` when relevant. At session end, skim `observation_list` and either
-`observation_dismiss` or `observation_promote` for what has accumulated.
-
-**You fix bugs in your currently defined scope. You do NOT use observations
-to finish work prematurely.** If a defect, gap, or follow-up belongs to your
-current task, you own it — handle it as part of that task: fix it now, expand
-the task's scope, file a proper issue with a dependency, or surface it to the
-user. Filing it as an observation and closing the task is *not* completing
-the task; it is shipping known-broken work and hiding the debt in a 14-day
-expiring scratchpad. The test is "would I have noticed this even if I weren't
-working on this task?" If no, it's task scope, not an observation.
-
-### Priority scale
-
-- P0: Critical (drop everything)
-- P1: High (do next)
-- P2: Medium (default)
-- P3: Low
-- P4: Backlog
-
-### Reaching for tools
-
-MCP tool schemas describe each tool; `filigree --help` and `filigree 
---help` are the authoritative CLI reference. You do not need to memorise
-either catalogue. The verbs you will reach for most:
-
-- **Find work:** `work_ready`, `work_blocked`, `issue_list`, `issue_search`
-- **Claim work:** `work_start`, `work_start_next`
-- **Update:** `comment_add`, `label_add`, `issue_update`, `issue_close`
-- **Admin (irreversible):** `issue_delete` (MCP) / `delete-issue` (CLI) —
-  hard-deletes a terminal issue and its rows; `admin_undo_last` cannot reverse it.
-- **Scratchpad:** `observation_create`, `observation_list`, `observation_promote`, `observation_dismiss`
-- **Cross-product entity bindings (ADR-029):** `entity_association_add`,
-  `entity_association_remove`, `entity_association_list`,
-  `entity_association_list_by_entity`. Used when a sibling tool (e.g.
-  Clarion) needs to bind a Filigree issue to a function, class, or
-  module identifier it owns. The `entity_id` is an opaque string
-  from Filigree's perspective; the consumer (the sibling tool's read
-  path) does drift detection against the stored
-  `content_hash_at_attach`. `entity_association_list_by_entity` is the
-  reverse-lookup surface — given a Clarion entity ID, return every
-  Filigree issue bound to it (project isolation is by DB file). Also
-  reachable over HTTP as
-  `GET/POST /api/issue/{issue_id}/entity-associations`,
-  `DELETE /api/issue/{issue_id}/entity-associations?entity_id=…`,
-  and `GET /api/entity-associations?entity_id=…`.
-- **Health:** `stats_get`, `metrics_get`, `mcp_status_get`
-
-Pass `--actor ` (CLI) so events attribute to your agent identity. It
-works in either position — before the verb (`filigree --actor X update …`) or
-after it (`filigree update … --actor X`); the post-verb value overrides the
-group-level one.
-
-### Error handling
-
-Errors return `{error: str, code: ErrorCode, details?: dict}`. Switch on
-`code`, not on message text. Codes: `VALIDATION`, `NOT_FOUND`, `CONFLICT`,
-`INVALID_TRANSITION`, `PERMISSION`, `NOT_INITIALIZED`, `IO`,
-`INVALID_API_URL`, `FILE_REGISTRY_DISPLACED`, `REGISTRY_UNAVAILABLE`,
-`CLARION_REGISTRY_VERSION_MISMATCH`, `BRIEFING_BLOCKED`, `STOP_FAILED`,
-`SCHEMA_MISMATCH`, `INTERNAL`.
-
-On `INVALID_TRANSITION`, call `workflow_transition_list` (MCP) or
-`filigree transitions ` to see what the workflow allows from here.
-
-Two failure modes deserve a specific response:
-
-- **`SCHEMA_MISMATCH`** — the installed `filigree` is older than the project
-  database. The error message contains upgrade guidance. Surface it to the
-  user; do not retry.
-- **`ForeignDatabaseError`** — filigree found a parent project's database
-  but no local `.filigree.conf`. Run `filigree init` in the current
-  directory. Do **not** `cd` upward to a different project unless that was
-  the actual intent.
-

From cf4d877dfae0f8725b89f1555656367a49999aac Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 04:56:51 +1000
Subject: [PATCH 11/21] =?UTF-8?q?chore:=20tidy=20.gitignore=20=E2=80=94=20?=
 =?UTF-8?q?regroup=20clarion=20runtime=20+=20filigree-managed=20docs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Move .clarion/clarion.lock into the Clarion runtime-artifacts group (was
orphaned under the site-build comment) and gather the filigree-managed docs
(CLAUDE.md, AGENTS.md, filigree-workflow SKILL.md) under an explanatory
comment. No change to which paths are ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 .gitignore | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore
index de27e2c6..d0293346 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,14 +30,19 @@ tests/e2e/external-operator-smoke-results-*.md
 # Generated skill fingerprint (rewritten by `clarion install --skills`).
 .agents/skills/clarion-workflow/.fingerprint
 
-# Clarion runtime artifacts — the index DB and per-project instance fingerprint
-# change on every analyze run, so they are not tracked (see .clarion/.gitignore).
+# Clarion runtime artifacts — the index DB, per-project instance fingerprint,
+# and analyze lock change on every run, so they are not tracked
+# (see .clarion/.gitignore).
 .clarion/clarion.db
 .clarion/instance_id
+.clarion/clarion.lock
 
 # Documentation site build output (mkdocs `site_dir`, web/mkdocs.yml).
 /site-build/
-.clarion/clarion.lock
+
+# Filigree-managed docs — a running filigree process rewrites its managed
+# instruction blocks in these every session; untracked to avoid diff churn
+# (filigree regenerates them on demand).
 AGENTS.md
 CLAUDE.md
 .agents/skills/filigree-workflow/SKILL.md

From 70fe6d39051bbcfdf10f452650cf0ea88a370fdc Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 05:28:17 +1000
Subject: [PATCH 12/21] Move release governance handoff to Legis

---
 .github/workflows/ci.yml                   |  12 +-
 .github/workflows/release.yml              |  26 +-
 docs/operator/README.md                    |   6 +-
 docs/operator/clarion-http-read-api.md     |   2 +-
 docs/operator/release-handoff.md           |  38 +
 docs/operator/secret-scanning.md           |   2 +-
 docs/operator/v1.0-release-governance.md   | 219 +----
 docs/operator/v1.0-release-rollback.md     |  21 +-
 scripts/check-github-release-governance.py | 947 ---------------------
 9 files changed, 62 insertions(+), 1211 deletions(-)
 create mode 100644 docs/operator/release-handoff.md
 delete mode 100755 scripts/check-github-release-governance.py

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7f7d2a46..736cdfdd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,10 +34,9 @@ jobs:
 
       - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
 
-      # Python is used by the migration-retirement guard and the
-      # workspace-version-lockstep guard below. Pin >= 3.11 explicitly so the
-      # stdlib `tomllib` is available regardless of which Python the runner
-      # image happens to preinstall.
+      # Python is used by the migration-retirement guard and lockstep guards
+      # below. Pin >= 3.11 explicitly so the stdlib `tomllib` is available
+      # regardless of which Python the runner image happens to preinstall.
       - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
         with:
           python-version: "3.11"
@@ -50,11 +49,6 @@ jobs:
           python scripts/check-migration-retirement.py --self-test
           python scripts/check-migration-retirement.py
 
-      - name: release governance static guard
-        run: |
-          python scripts/check-github-release-governance.py --self-test
-          python scripts/check-github-release-governance.py --static-only
-
       - name: cross-workspace version lockstep
         run: python scripts/check-workspace-version-lockstep.py
 
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index aaa2235c..58835828 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -67,11 +67,6 @@ jobs:
           python scripts/check-migration-retirement.py --self-test
           python scripts/check-migration-retirement.py
 
-      - name: release governance static guard
-        run: |
-          python scripts/check-github-release-governance.py --self-test
-          python scripts/check-github-release-governance.py --static-only
-
       - name: cross-workspace version lockstep
         run: python scripts/check-workspace-version-lockstep.py
 
@@ -162,25 +157,8 @@ jobs:
       - name: Phase 3 subsystems
         run: CARGO_BUILD=0 bash tests/e2e/phase3_subsystems.sh
 
-  release-governance:
-    name: GitHub release governance
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-    steps:
-      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
-
-      - name: enforce repository release controls
-        env:
-          GH_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
-        run: |
-          set -euo pipefail
-          python scripts/check-github-release-governance.py \
-            --repository "${GITHUB_REPOSITORY}" \
-            --branch main
-
   build-rust:
-    needs: [verify, release-governance]
+    needs: [verify]
     name: Build clarion (${{ matrix.target }})
     runs-on: ${{ matrix.runner }}
     strategy:
@@ -246,7 +224,7 @@ jobs:
           retention-days: 7
 
   build-plugin:
-    needs: [verify, release-governance]
+    needs: [verify]
     name: Build Python plugin sdist
     runs-on: ubuntu-latest
     steps:
diff --git a/docs/operator/README.md b/docs/operator/README.md
index 065c5e4e..ae0714d1 100644
--- a/docs/operator/README.md
+++ b/docs/operator/README.md
@@ -16,8 +16,8 @@ Practical notes for configuring and running Clarion.
 - [Guidance](./guidance.md) — authoring guidance sheets with the `clarion
   guidance` CLI, `--match`/`--scope-level`/`--expires` semantics, staleness
   findings, and the export/import team-sharing workflow.
-- [Release governance](./v1.0-release-governance.md) — maintainer steps
-  for GitHub branch/ruleset enforcement, Actions policy, release dry run, and
-  final tag gating.
+- [Release handoff](./release-handoff.md) — retired
+  Clarion-owned GitHub ruleset enforcement and current standalone release
+  sequence.
 - [Federation contracts](../federation/contracts.md) — read-side HTTP
   contracts consumed by sibling products such as Filigree.
diff --git a/docs/operator/clarion-http-read-api.md b/docs/operator/clarion-http-read-api.md
index d333b068..725209ca 100644
--- a/docs/operator/clarion-http-read-api.md
+++ b/docs/operator/clarion-http-read-api.md
@@ -64,7 +64,7 @@ catalog, or unavailable because of storage errors.
 
 When both `serve.http.token_env` (legacy bearer) and
 `serve.http.identity_token_env` (HMAC, preferred per
-[ADR-034](../clarion/adr/ADR-034-federation-hardening.md)) are unset and the
+[ADR-034](../clarion/adr/ADR-034-federation-http-read-api-hardening.md)) are unset and the
 bind is loopback (default: `127.0.0.1:9111`), the HTTP read API serves
 unauthenticated. This is the intended single-user developer-workstation
 trust model — the loopback socket is reachable only from processes on the
diff --git a/docs/operator/release-handoff.md b/docs/operator/release-handoff.md
new file mode 100644
index 00000000..c5d49368
--- /dev/null
+++ b/docs/operator/release-handoff.md
@@ -0,0 +1,38 @@
+# Release Handoff
+
+Clarion no longer owns live GitHub branch/ruleset release-control enforcement.
+
+The old v1.0 guard required Clarion's release workflow to inspect repository
+branch protection, tag rulesets, Actions policy, and a dedicated maintainer
+secret before build jobs could start. That requirement is retired for Clarion.
+Governance enforcement is moving to Legis, which is delivered alongside the
+next Clarion release.
+
+Clarion's standalone release semantics are now:
+
+- the release workflow verifies the source tree, builds artifacts, and publishes
+  GitHub Releases for `v*` tags;
+- Clarion does not require live repository ruleset inspection to build or
+  publish its own artifacts;
+- any Legis governance signal is external enrichment and must not become a
+  prerequisite for Clarion to remain useful alone.
+
+Keep future Clarion release controls local to Clarion's own artifact integrity
+unless a new accepted ADR says otherwise. If Legis publishes an attestation for
+the same release train, link it from release notes or operator handoff material
+without adding a mandatory sibling dependency to Clarion's release workflow.
+
+## Current Release Sequence
+
+1. Confirm the release branch or PR is green under Clarion's CI floor.
+2. Run `.github/workflows/release.yml` with `workflow_dispatch` from the release
+   commit to validate the build and artifact path.
+3. Push the `v*` tag from the reviewed release commit.
+4. After the tag workflow finishes, download the public artifacts from the
+   GitHub Release and repeat the
+   [getting started walkthrough](./getting-started.md) on a clean machine or VM.
+
+## See Also
+
+- [`v1.0-release-rollback.md`](./v1.0-release-rollback.md) - post-publish
+  incident runbook.
diff --git a/docs/operator/secret-scanning.md b/docs/operator/secret-scanning.md
index e9ddadd4..b424a4a0 100644
--- a/docs/operator/secret-scanning.md
+++ b/docs/operator/secret-scanning.md
@@ -92,7 +92,7 @@ callers. The v1.0 HTTP API has one mode where it serves any local caller
 without authentication: **loopback bind with no token configured.**
 
 When both `serve.http.token_env` (legacy bearer) and `serve.http.identity_token_env`
-(HMAC, preferred per [ADR-034](../clarion/adr/ADR-034-federation-hardening.md))
+(HMAC, preferred per [ADR-034](../clarion/adr/ADR-034-federation-http-read-api-hardening.md))
 are unset and the bind is loopback (default: `127.0.0.1:9111`), the HTTP read
 API serves unauthenticated. On a single-user developer workstation this is
 the intended trust model: the loopback socket is reachable only from
diff --git a/docs/operator/v1.0-release-governance.md b/docs/operator/v1.0-release-governance.md
index d4339d6a..fa9c23f9 100644
--- a/docs/operator/v1.0-release-governance.md
+++ b/docs/operator/v1.0-release-governance.md
@@ -1,217 +1,6 @@
-# v1.0 Release Governance
+# Retired Page
 
-Clarion v1.0 publishes from GitHub Releases, not public package registries.
-Before the `v1.0.0` tag is cut, the repository owner must enable live GitHub
-controls that force the release commit through the reviewed PR and CI path.
+This operator page is retired.
 
-This page is intentionally narrow: it covers the GitHub settings required by
-`scripts/check-github-release-governance.py`. Do not change repository settings
-while a release is in progress unless you are the maintainer responsible for
-the release.
-
-## Required Controls
-
-The v1.0 tag gate requires:
-
-- `main` is protected by branch protection or an active repository ruleset.
-- The protection/ruleset targets `main`.
-- An active repository ruleset protects `refs/tags/v*`.
-- Pull-request flow is required before changes reach `main`.
-- These CI checks are required:
-  - `Rust`
-  - `Rust (aarch64-apple-darwin)`
-  - `Python plugin`
-  - `Sprint 1 walking skeleton (end-to-end)`
-- GitHub Actions are enabled.
-- Actions are not configured as `allowed_actions=all` unless SHA pinning is
-  also required.
-- Workflow action references stay pinned to full commit SHAs.
-- Dependabot keeps GitHub Actions pins on a weekly update lane.
-
-The source-tree checks run before any GitHub API calls. The live GitHub checks
-require a token that can read repository administration settings and Actions
-policy settings. A fine-grained token needs repository administration access for
-rulesets/branch protection and repository Actions policy access for
-`/actions/permissions`.
-
-Configure that token as a repository Actions secret named
-`RELEASE_GOVERNANCE_TOKEN` before running `.github/workflows/release.yml`.
-The release workflow uses this secret for the `GitHub release governance` job,
-and the build jobs do not start unless the guard passes.
-
-```bash
-python scripts/check-github-release-governance.py \
-  --repository tachyon-beep/clarion \
-  --branch main
-```
-
-## Recommended GitHub Settings
-
-Use either a repository ruleset or classic branch protection. A ruleset is
-preferred because it is easier to audit as one release gate.
-
-### Repository Ruleset
-
-Create an active repository ruleset with:
-
-- Target: branches.
-- Include: `refs/heads/main`.
-- Enforcement: active.
-- Do not use evaluate/monitor mode for the release gate; it reports violations
-  but does not enforce the reviewed release path.
-- Rules:
-  - require a pull request before merging;
-  - require status checks to pass;
-  - require the branch to be up to date before merge;
-  - require the four v1.0 CI checks named above.
-
-The REST shape for the ruleset is:
-
-```json
-{
-  "name": "v1.0 release gate",
-  "target": "branch",
-  "enforcement": "active",
-  "conditions": {
-    "ref_name": {
-      "include": ["refs/heads/main"],
-      "exclude": []
-    }
-  },
-  "rules": [
-    {
-      "type": "pull_request",
-      "parameters": {
-        "required_approving_review_count": 0,
-        "dismiss_stale_reviews_on_push": false,
-        "require_code_owner_review": false,
-        "require_last_push_approval": false,
-        "required_review_thread_resolution": true,
-        "automatic_copilot_code_review_enabled": false,
-        "allowed_merge_methods": ["merge"]
-      }
-    },
-    {
-      "type": "required_status_checks",
-      "parameters": {
-        "strict_required_status_checks_policy": true,
-        "required_status_checks": [
-          {"context": "Rust"},
-          {"context": "Rust (aarch64-apple-darwin)"},
-          {"context": "Python plugin"},
-          {"context": "Sprint 1 walking skeleton (end-to-end)"}
-        ]
-      }
-    }
-  ]
-}
-```
-
-### Release Tag Ruleset
-
-Create a separate active repository ruleset for release tags so a direct tag
-push cannot point `v*` tags at an arbitrary commit outside the reviewed release
-path.
-
-The minimal REST shape is:
-
-```json
-{
-  "name": "v1.0 release tag gate",
-  "target": "tag",
-  "enforcement": "active",
-  "conditions": {
-    "ref_name": {
-      "include": ["refs/tags/v*"],
-      "exclude": []
-    }
-  },
-  "rules": []
-}
-```
-
-### Actions Policy
-
-Set repository Actions permissions to keep Actions enabled, constrain allowed
-actions, and require SHA pinning. The repository workflows already pin external
-actions to full SHAs and Dependabot is configured to update those pins.
-
-The REST request body is:
-
-```json
-{
-  "enabled": true,
-  "allowed_actions": "selected",
-  "sha_pinning_required": true
-}
-```
-
-If GitHub rejects `sha_pinning_required`, check whether the repository is under
-an organization or enterprise policy that controls action-source restrictions.
-The release gate is not satisfied until the guard reports a constrained Actions
-policy.
-
-If the guard exits with code `2` and an HTTP 403 from `/actions/permissions`,
-the token cannot read repository Actions policy settings. Re-run with a
-maintainer token that has that permission before treating the release gate as
-evaluated.
-
-## Release Sequence
-
-1. Confirm PR #12 is green and clean:
-
-   ```bash
-   gh pr view 12 --json state,mergeStateStatus,statusCheckRollup,headRefOid
-   ```
-
-2. Enable the GitHub repository controls above.
-
-3. Configure the maintainer-scoped governance token:
-
-   ```bash
-   gh secret set RELEASE_GOVERNANCE_TOKEN --repo tachyon-beep/clarion
-   ```
-
-4. Run the governance guard locally with the same token scope and require a
-   pass:
-
-   ```bash
-   python scripts/check-github-release-governance.py \
-     --repository tachyon-beep/clarion \
-     --branch main
-   ```
-
-5. Merge PR #12 to `main`.
-
-6. Run the release workflow manually from `main` before creating the tag:
-
-   ```bash
-   gh workflow run release.yml --ref main
-   gh run list --workflow release.yml --limit 1
-   gh run watch  --exit-status
-   ```
-
-   `workflow_dispatch` validates the build and artifact path but does not
-   publish a GitHub Release. It also runs the same governance guard with
-   `RELEASE_GOVERNANCE_TOKEN`; do not proceed if that job fails.
-
-7. Run the governance guard again immediately before pushing `v1.0.0`.
-
-8. Push the release tag only after the guard and release dry run are green.
-
-9. After the tag workflow finishes, download the public Linux archive and
-   Python sdist from the Release and repeat the
-   [getting started walkthrough](./getting-started.md) on a clean machine or VM.
-
-## See also
-
-- [`v1.0-release-rollback.md`](./v1.0-release-rollback.md) — post-publish
-  incident runbook (yanking a bad release, supersession via v1.0.1,
-  Rekor non-revocability, downstream notification, postmortem).
-
-## References
-
-- GitHub REST API: repository rulesets:
-  
-- GitHub REST API: Actions permissions:
-  
+Clarion's current release handoff and tag-cut sequence live in
+[`release-handoff.md`](./release-handoff.md).
diff --git a/docs/operator/v1.0-release-rollback.md b/docs/operator/v1.0-release-rollback.md
index d8ec72cb..1d34955f 100644
--- a/docs/operator/v1.0-release-rollback.md
+++ b/docs/operator/v1.0-release-rollback.md
@@ -1,10 +1,9 @@
 # v1.0 Release Rollback / Yank Runbook
 
 This runbook covers the h+30min "we shipped a bad release" scenario for
-`v1.0.0` (or any later 1.x tag) once the GitHub Release is public. It is the
-sibling of [`v1.0-release-governance.md`](./v1.0-release-governance.md), which
-covers the *pre-publish* controls; this page covers the *post-publish*
-incident path.
+`v1.0.0` (or any later 1.x tag) once the GitHub Release is public. It covers
+the *post-publish* incident path; the current release sequence is summarized in
+[`release-handoff.md`](./release-handoff.md).
 
 Read this page top to bottom before taking any action. The order of steps
 matters: removing the "latest" pointer is reversible; deleting assets is not,
@@ -78,9 +77,8 @@ not assume the Rekor entry can be removed — it cannot.
 
 ## 4. v1.0.1 publication — the supersession mechanism
 
-The standard tag-cut procedure from
-[`v1.0-release-governance.md`](./v1.0-release-governance.md) applies, with
-one addition: the v1.0.1 release body **must** carry a supersession note.
+The standard Clarion tag-cut procedure applies, with one addition: the v1.0.1
+release body **must** carry a supersession note.
 Suggested body opening:
 
 ```markdown
@@ -93,8 +91,9 @@ upgrade to v1.0.1; the v1.0.0 cosign signatures and Rekor entries remain
 verifiable but should not be used to gate new installs.
 ```
 
-Run the full governance guard before tagging v1.0.1 — none of the gates from
-the governance doc are waived for an incident-driven release.
+Run the full Clarion CI and release dry run before tagging v1.0.1. Legis-owned
+governance may publish companion evidence for the release train, but Clarion no
+longer requires a live GitHub governance guard to publish its own artifacts.
 
 ## 5. Downstream notification
 
@@ -135,7 +134,7 @@ the `incident` label so the post-incident review pass can find it.
 
 ## See also
 
-- [`v1.0-release-governance.md`](./v1.0-release-governance.md) — pre-publish
-  controls and the tag-cut procedure that v1.0.1 also runs.
+- [`release-handoff.md`](./release-handoff.md) — governance handoff and current
+  tag-cut sequence.
 - [`getting-started.md`](./getting-started.md) — the public install path that
   the supersession is protecting.
diff --git a/scripts/check-github-release-governance.py b/scripts/check-github-release-governance.py
deleted file mode 100755
index 731f8557..00000000
--- a/scripts/check-github-release-governance.py
+++ /dev/null
@@ -1,947 +0,0 @@
-#!/usr/bin/env python3
-"""Check GitHub-side release governance before cutting a Clarion tag.
-
-The release workflow can prove build and artifact integrity once it runs, but
-some 1.0 release controls live outside the repository tree: branch protection,
-repository rulesets, and the Actions source policy. This guard queries the live
-GitHub REST API and fails when those controls are still permissive.
-
-Exit codes:
-    0  release governance is non-permissive enough for the v1.0 tag gate
-    1  the live settings are too permissive
-    2  usage, authentication, or API access error
-"""
-
-from __future__ import annotations
-
-import argparse
-import json
-import os
-import re
-import subprocess
-import sys
-import tempfile
-import urllib.error
-import urllib.request
-from collections.abc import Callable
-from dataclasses import dataclass
-from fnmatch import fnmatchcase
-from pathlib import Path
-from typing import Any
-
-
-class CheckError(Exception):
-    """Raised when release governance is too permissive."""
-
-
-class UsageError(Exception):
-    """Raised when the guard cannot query GitHub correctly."""
-
-
-FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
-USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P\S+)\s*$")
-JOB_RE = re.compile(r"^  (?P[A-Za-z0-9_-]+):\s*(?:#.*)?$")
-REQUIRED_STATUS_CHECKS = frozenset(
-    {
-        "Rust",
-        "Rust (aarch64-apple-darwin)",
-        "Python plugin",
-        "Sprint 1 walking skeleton (end-to-end)",
-    }
-)
-REQUIRED_GOVERNANCE_DOC_SNIPPETS = frozenset(
-    {
-        "active repository ruleset protects `refs/tags/v*`",
-        '"target": "tag"',
-        '"include": ["refs/tags/v*"]',
-        "Pull-request flow is required before changes reach `main`.",
-        "Workflow action references stay pinned to full commit SHAs.",
-    }
-)
-
-
-@dataclass(frozen=True)
-class ApiResponse:
-    status: int
-    payload: Any
-
-
-class GitHubClient:
-    """Tiny GitHub REST client using only the Python standard library."""
-
-    def __init__(self, token: str, api_base: str = "https://api.github.com") -> None:
-        self.token = token
-        self.api_base = api_base.rstrip("/")
-
-    def get(self, path: str) -> ApiResponse:
-        url = f"{self.api_base}{path}"
-        request = urllib.request.Request(
-            url,
-            headers={
-                "Accept": "application/vnd.github+json",
-                "Authorization": f"Bearer {self.token}",
-                "User-Agent": "clarion-release-governance-check",
-                "X-GitHub-Api-Version": "2022-11-28",
-            },
-            method="GET",
-        )
-        try:
-            with urllib.request.urlopen(request, timeout=20) as response:
-                body = response.read().decode("utf-8")
-                return ApiResponse(response.status, json.loads(body) if body else None)
-        except urllib.error.HTTPError as exc:
-            body = exc.read().decode("utf-8")
-            try:
-                payload: Any = json.loads(body) if body else None
-            except json.JSONDecodeError:
-                payload = body
-            return ApiResponse(exc.code, payload)
-        except OSError as exc:
-            raise UsageError(f"GitHub API request failed: {exc}") from exc
-
-
-def require_mapping(value: Any, label: str) -> dict[str, Any]:
-    if not isinstance(value, dict):
-        raise UsageError(f"{label} returned unexpected payload: {value!r}")
-    return value
-
-
-def require_list(value: Any, label: str) -> list[Any]:
-    if not isinstance(value, list):
-        raise UsageError(f"{label} returned unexpected payload: {value!r}")
-    return value
-
-
-def api_message(payload: Any) -> str:
-    if isinstance(payload, dict) and isinstance(payload.get("message"), str):
-        return payload["message"]
-    return repr(payload)
-
-
-def branch_protection(
-    request_json: Callable[[str], ApiResponse],
-    repository: str,
-    branch: str,
-) -> dict[str, Any] | None:
-    response = request_json(f"/repos/{repository}/branches/{branch}/protection")
-    if response.status == 200:
-        return require_mapping(response.payload, "branch protection")
-    if response.status == 404:
-        return None
-    raise UsageError(
-        f"cannot inspect branch protection for {repository}@{branch}: "
-        f"HTTP {response.status} {api_message(response.payload)}"
-    )
-
-
-def repository_rulesets(
-    request_json: Callable[[str], ApiResponse],
-    repository: str,
-) -> list[dict[str, Any]]:
-    response = request_json(f"/repos/{repository}/rulesets")
-    if response.status != 200:
-        raise UsageError(
-            f"cannot inspect repository rulesets for {repository}: "
-            f"HTTP {response.status} {api_message(response.payload)}"
-        )
-    rulesets = require_list(response.payload, "repository rulesets")
-    return [require_mapping(item, "repository ruleset") for item in rulesets]
-
-
-def repository_ruleset_detail(
-    request_json: Callable[[str], ApiResponse],
-    repository: str,
-    ruleset: dict[str, Any],
-) -> dict[str, Any]:
-    ruleset_id = ruleset.get("id")
-    if not isinstance(ruleset_id, int):
-        return ruleset
-    response = request_json(f"/repos/{repository}/rulesets/{ruleset_id}")
-    if response.status != 200:
-        raise UsageError(
-            f"cannot inspect repository ruleset {ruleset_id} for {repository}: "
-            f"HTTP {response.status} {api_message(response.payload)}"
-        )
-    return require_mapping(response.payload, "repository ruleset detail")
-
-
-def actions_permissions(
-    request_json: Callable[[str], ApiResponse],
-    repository: str,
-) -> dict[str, Any]:
-    response = request_json(f"/repos/{repository}/actions/permissions")
-    if response.status != 200:
-        raise UsageError(
-            f"cannot inspect Actions permissions for {repository}: "
-            f"HTTP {response.status} {api_message(response.payload)}"
-        )
-    return require_mapping(response.payload, "Actions permissions")
-
-
-def branch_pattern_matches(pattern: str, branch: str) -> bool:
-    if pattern in {branch, f"refs/heads/{branch}", "~DEFAULT_BRANCH"}:
-        return True
-    normalized = pattern.removeprefix("refs/heads/")
-    return fnmatchcase(branch, normalized)
-
-
-def ruleset_targets_branch(ruleset: dict[str, Any], branch: str) -> bool:
-    target = ruleset.get("target")
-    if target not in {None, "branch"}:
-        return False
-
-    conditions = ruleset.get("conditions")
-    if not isinstance(conditions, dict):
-        return True
-    ref_name = conditions.get("ref_name")
-    if not isinstance(ref_name, dict):
-        return True
-
-    exclude = ref_name.get("exclude")
-    if isinstance(exclude, list) and any(
-        isinstance(pattern, str) and branch_pattern_matches(pattern, branch)
-        for pattern in exclude
-    ):
-        return False
-
-    include = ref_name.get("include")
-    if not isinstance(include, list) or not include:
-        return True
-    return any(
-        isinstance(pattern, str) and branch_pattern_matches(pattern, branch)
-        for pattern in include
-    )
-
-
-def ruleset_targets_tag(ruleset: dict[str, Any], tag_pattern: str) -> bool:
-    """True when an active tag ruleset's include patterns cover ``tag_pattern``.
-
-    GitHub tag rulesets carry ``target == "tag"`` and condition their
-    ``ref_name`` on ``refs/tags/`` globs. We accept a ruleset if it either
-    has no narrowing ``include`` list (covers all tags) or names an include
-    pattern that the desired ``tag_pattern`` (e.g. ``refs/tags/v*``) satisfies.
-    """
-    if ruleset.get("target") != "tag":
-        return False
-
-    conditions = ruleset.get("conditions")
-    if not isinstance(conditions, dict):
-        return True
-    ref_name = conditions.get("ref_name")
-    if not isinstance(ref_name, dict):
-        return True
-
-    include = ref_name.get("include")
-    if not isinstance(include, list) or not include:
-        return True
-    # The desired pattern is itself a glob (refs/tags/v*); treat an include
-    # entry as covering it when the strings match, when the include is the
-    # all-tags wildcard, or when the include glob matches the literal prefix
-    # of the desired pattern.
-    desired = tag_pattern.removeprefix("refs/tags/")
-    for pattern in include:
-        if not isinstance(pattern, str):
-            continue
-        normalized = pattern.removeprefix("refs/tags/")
-        if pattern in {tag_pattern, "~ALL"} or normalized in {desired, "*"}:
-            return True
-        # An include like `v*` matches the concrete tag families we cut.
-        if fnmatchcase("v1.0.0", normalized) or fnmatchcase(desired, normalized):
-            return True
-    return False
-
-
-def branch_protection_status_checks(protection: dict[str, Any]) -> set[str]:
-    required = protection.get("required_status_checks")
-    if not isinstance(required, dict):
-        return set()
-
-    contexts = {
-        context
-        for context in required.get("contexts", [])
-        if isinstance(context, str)
-    }
-    checks = {
-        check.get("context")
-        for check in required.get("checks", [])
-        if isinstance(check, dict) and isinstance(check.get("context"), str)
-    }
-    return contexts | checks
-
-
-def ruleset_status_checks(ruleset: dict[str, Any]) -> set[str]:
-    checks: set[str] = set()
-    rules = ruleset.get("rules")
-    if not isinstance(rules, list):
-        return checks
-
-    for rule in rules:
-        if not isinstance(rule, dict) or rule.get("type") != "required_status_checks":
-            continue
-        parameters = rule.get("parameters")
-        if not isinstance(parameters, dict):
-            continue
-        for check in parameters.get("required_status_checks", []):
-            if isinstance(check, dict) and isinstance(check.get("context"), str):
-                checks.add(check["context"])
-            elif isinstance(check, str):
-                checks.add(check)
-    return checks
-
-
-def ruleset_requires_pull_request(ruleset: dict[str, Any]) -> bool:
-    rules = ruleset.get("rules")
-    if not isinstance(rules, list):
-        return False
-    return any(isinstance(rule, dict) and rule.get("type") == "pull_request" for rule in rules)
-
-
-def protection_requires_pull_request(protection: dict[str, Any]) -> bool:
-    return isinstance(protection.get("required_pull_request_reviews"), dict)
-
-
-def missing_required_status_checks(actual: set[str]) -> set[str]:
-    return REQUIRED_STATUS_CHECKS - actual
-
-
-def check_governance(
-    request_json: Callable[[str], ApiResponse],
-    repository: str,
-    branch: str,
-) -> list[str]:
-    failures: list[str] = []
-    notes: list[str] = []
-
-    protection = branch_protection(request_json, repository, branch)
-    rule_summaries = repository_rulesets(request_json, repository)
-    active_rulesets = [
-        repository_ruleset_detail(request_json, repository, item)
-        for item in rule_summaries
-        if item.get("enforcement") == "active" and ruleset_targets_branch(item, branch)
-    ]
-
-    protected_path_ok = False
-    if protection is not None:
-        missing_checks = missing_required_status_checks(branch_protection_status_checks(protection))
-        if missing_checks:
-            failures.append(
-                f"{branch}: branch protection is missing required CI checks: "
-                f"{', '.join(sorted(missing_checks))}"
-            )
-        elif not protection_requires_pull_request(protection):
-            failures.append(
-                f"{branch}: branch protection does not require pull-request review flow; "
-                "direct pushes can bypass the release PR path"
-            )
-        else:
-            protected_path_ok = True
-            notes.append(
-                f"{branch}: branch protection requires pull-request flow and "
-                f"{len(REQUIRED_STATUS_CHECKS)} release CI checks"
-            )
-
-    ruleset_path_ok = False
-    for ruleset in active_rulesets:
-        name = ruleset.get("name", "")
-        if not isinstance(name, str):
-            name = ""
-        missing_checks = missing_required_status_checks(ruleset_status_checks(ruleset))
-        has_pr_rule = ruleset_requires_pull_request(ruleset)
-        if not missing_checks and has_pr_rule:
-            ruleset_path_ok = True
-            notes.append(
-                f"{repository}: active ruleset {name!r} requires pull-request flow and "
-                f"{len(REQUIRED_STATUS_CHECKS)} release CI checks"
-            )
-
-    if active_rulesets and not ruleset_path_ok:
-        failures.append(
-            f"{repository}: active repository rulesets targeting {branch} do not require "
-            "both pull-request flow and the release CI checks"
-        )
-    if protection is None and not active_rulesets:
-        failures.append(
-            f"{branch}: no branch protection and no active repository rulesets; "
-            "tag provenance can bypass the reviewed PR path"
-        )
-    elif not protected_path_ok and not ruleset_path_ok:
-        failures.append(
-            f"{branch}: no branch protection or ruleset currently proves the reviewed "
-            "release PR path"
-        )
-
-    # Tag protection (GOV-02): an active ruleset must target `refs/tags/v*` so
-    # that a tag-push cannot point a release tag at an arbitrary commit. The
-    # legacy `tags/protection` endpoint is deprecated; a tag ruleset is the
-    # modern mechanism. We assert on the ruleset summaries (no detail fetch is
-    # needed for a presence check).
-    tag_pattern = "refs/tags/v*"
-    active_tag_rulesets = [
-        item.get("name", "")
-        for item in rule_summaries
-        if item.get("enforcement") == "active" and ruleset_targets_tag(item, tag_pattern)
-    ]
-    if active_tag_rulesets:
-        names = ", ".join(
-            name if isinstance(name, str) else ""
-            for name in active_tag_rulesets
-        )
-        notes.append(
-            f"{repository}: active tag ruleset(s) protect {tag_pattern} ({names})"
-        )
-    else:
-        failures.append(
-            f"{repository}: no active repository ruleset protects {tag_pattern}; "
-            "a tag-push can point a release tag at an arbitrary commit"
-        )
-
-    permissions = actions_permissions(request_json, repository)
-    if permissions.get("enabled") is not True:
-        failures.append("GitHub Actions are not enabled for the repository")
-    allowed_actions = permissions.get("allowed_actions")
-    sha_pinning_required = permissions.get("sha_pinning_required")
-    if allowed_actions == "all" and sha_pinning_required is not True:
-        failures.append(
-            "Actions source policy is permissive: allowed_actions=all and "
-            "sha_pinning_required is not true"
-        )
-    else:
-        notes.append(
-            "Actions source policy is constrained "
-            f"(allowed_actions={allowed_actions!r}, "
-            f"sha_pinning_required={sha_pinning_required!r})"
-        )
-
-    if failures:
-        raise CheckError("\n".join(failures))
-    return notes
-
-
-def check_workflow_action_pins(repo_root: Path) -> list[str]:
-    workflow_dir = repo_root / ".github" / "workflows"
-    if not workflow_dir.is_dir():
-        raise UsageError(f"workflow directory not found: {workflow_dir}")
-
-    failures: list[str] = []
-    checked = 0
-    for workflow in sorted([*workflow_dir.glob("*.yml"), *workflow_dir.glob("*.yaml")]):
-        for line_number, line in enumerate(workflow.read_text(encoding="utf-8").splitlines(), 1):
-            match = USES_RE.match(line)
-            if match is None:
-                continue
-            target = match.group("target")
-            if target.startswith(("./", "docker://")):
-                continue
-            if "@" not in target:
-                failures.append(f"{workflow}:{line_number}: external action is not pinned: {target}")
-                continue
-            ref = target.rsplit("@", maxsplit=1)[1]
-            checked += 1
-            if not FULL_SHA_RE.fullmatch(ref):
-                failures.append(
-                    f"{workflow}:{line_number}: action ref is not a full commit SHA: {target}"
-                )
-
-    if failures:
-        raise CheckError("\n".join(failures))
-    return [f"workflow action refs are full-length commit SHAs ({checked} uses entries checked)"]
-
-
-def check_dependabot_github_actions_updates(repo_root: Path) -> list[str]:
-    config = repo_root / ".github" / "dependabot.yml"
-    if not config.is_file():
-        raise CheckError(f"{config}: Dependabot config is missing")
-
-    current: dict[str, str] = {}
-    entries: list[dict[str, str]] = []
-    for raw_line in config.read_text(encoding="utf-8").splitlines():
-        stripped = raw_line.strip()
-        if stripped.startswith("- package-ecosystem:"):
-            if current:
-                entries.append(current)
-            current = {"package-ecosystem": stripped.split(":", maxsplit=1)[1].strip(" '\"")}
-        elif current and stripped.startswith("directory:"):
-            current["directory"] = stripped.split(":", maxsplit=1)[1].strip(" '\"")
-        elif current and stripped.startswith("interval:"):
-            current["interval"] = stripped.split(":", maxsplit=1)[1].strip(" '\"")
-    if current:
-        entries.append(current)
-
-    for entry in entries:
-        if entry.get("package-ecosystem") == "github-actions" and entry.get("directory") == "/":
-            interval = entry.get("interval")
-            if not interval:
-                raise CheckError(f"{config}: github-actions Dependabot entry has no schedule interval")
-            return [f"Dependabot watches GitHub Actions pins on / ({interval})"]
-
-    raise CheckError(
-        f"{config}: missing package-ecosystem=github-actions entry for directory=/; "
-        "pinned workflow actions need a scheduled update path"
-    )
-
-
-def release_workflow_job_blocks(workflow: Path) -> dict[str, list[str]]:
-    jobs: dict[str, list[str]] = {}
-    current_job: str | None = None
-    in_jobs = False
-
-    for raw_line in workflow.read_text(encoding="utf-8").splitlines():
-        if raw_line == "jobs:":
-            in_jobs = True
-            current_job = None
-            continue
-        if in_jobs and raw_line and not raw_line.startswith((" ", "#")):
-            break
-        if not in_jobs:
-            continue
-
-        match = JOB_RE.match(raw_line)
-        if match is not None:
-            current_job = match.group("name")
-            jobs[current_job] = []
-            continue
-        if current_job is not None:
-            jobs[current_job].append(raw_line)
-
-    return jobs
-
-
-def release_workflow_needs(job_lines: list[str]) -> set[str]:
-    needs: set[str] = set()
-    in_needs_block = False
-    for line in job_lines:
-        stripped = line.strip()
-        if stripped.startswith("needs:"):
-            in_needs_block = True
-            value = stripped.split(":", maxsplit=1)[1].strip()
-            if value.startswith("[") and value.endswith("]"):
-                needs.update(
-                    item.strip(" '\"") for item in value[1:-1].split(",") if item.strip()
-                )
-            elif value:
-                needs.add(value.strip(" '\""))
-            continue
-        if in_needs_block:
-            if stripped.startswith("- "):
-                needs.add(stripped[2:].strip(" '\""))
-                continue
-            if line.startswith("    ") and not line.startswith("      "):
-                in_needs_block = False
-    return needs
-
-
-def check_release_workflow_governance_gate(repo_root: Path) -> list[str]:
-    workflow = repo_root / ".github" / "workflows" / "release.yml"
-    if not workflow.is_file():
-        raise CheckError(f"{workflow}: release workflow is missing")
-
-    jobs = release_workflow_job_blocks(workflow)
-    failures: list[str] = []
-    if "release-governance" not in jobs:
-        failures.append(f"{workflow}: missing release-governance job")
-
-    for job_name in ("build-rust", "build-plugin"):
-        if job_name not in jobs:
-            failures.append(f"{workflow}: missing {job_name} job")
-            continue
-        needs = release_workflow_needs(jobs[job_name])
-        if "release-governance" not in needs:
-            failures.append(
-                f"{workflow}: {job_name} must need release-governance before release artifacts build"
-            )
-
-    if failures:
-        raise CheckError("\n".join(failures))
-    return ["release workflow build jobs require the GitHub governance gate"]
-
-
-def check_governance_doc_lockstep(repo_root: Path) -> list[str]:
-    doc = repo_root / "docs" / "operator" / "v1.0-release-governance.md"
-    if not doc.is_file():
-        raise CheckError(f"{doc}: release governance operator doc is missing")
-    text = doc.read_text(encoding="utf-8")
-    missing = sorted(
-        snippet
-        for snippet in REQUIRED_GOVERNANCE_DOC_SNIPPETS
-        if snippet not in text
-    )
-    if missing:
-        raise CheckError(
-            f"{doc}: missing documented release governance control(s): "
-            + ", ".join(repr(item) for item in missing)
-        )
-    return ["release governance operator doc covers every required live control"]
-
-
-def run_self_test() -> None:
-    responses: dict[str, ApiResponse] = {
-        "/repos/acme/clarion/branches/main/protection": ApiResponse(
-            404, {"message": "Branch not protected"}
-        ),
-        "/repos/acme/clarion/rulesets": ApiResponse(200, []),
-        "/repos/acme/clarion/actions/permissions": ApiResponse(
-            200,
-            {
-                "enabled": True,
-                "allowed_actions": "all",
-                "sha_pinning_required": False,
-            },
-        ),
-    }
-
-    def fake_get(path: str) -> ApiResponse:
-        return responses[path]
-
-    try:
-        check_governance(fake_get, "acme/clarion", "main")
-    except CheckError as exc:
-        message = str(exc)
-        assert "no branch protection" in message
-        assert "allowed_actions=all" in message
-    else:
-        raise AssertionError("permissive fixture should fail")
-
-    responses["/repos/acme/clarion/actions/permissions"] = ApiResponse(
-        200,
-        {
-            "enabled": True,
-            "allowed_actions": "selected",
-            "sha_pinning_required": True,
-        },
-    )
-    responses["/repos/acme/clarion/rulesets"] = ApiResponse(
-        200,
-        [
-            {
-                "id": 42,
-                "name": "weak release gate",
-                "target": "branch",
-                "enforcement": "active",
-                "conditions": {"ref_name": {"include": ["refs/heads/main"], "exclude": []}},
-            }
-        ],
-    )
-    responses["/repos/acme/clarion/rulesets/42"] = ApiResponse(
-        200,
-        {
-            "id": 42,
-            "name": "weak release gate",
-            "target": "branch",
-            "enforcement": "active",
-            "conditions": {"ref_name": {"include": ["refs/heads/main"], "exclude": []}},
-            "rules": [{"type": "pull_request", "parameters": {}}],
-        },
-    )
-    try:
-        check_governance(fake_get, "acme/clarion", "main")
-    except CheckError as exc:
-        assert "do not require both pull-request flow and the release CI checks" in str(exc)
-    else:
-        raise AssertionError("weak ruleset fixture should fail")
-
-    responses["/repos/acme/clarion/rulesets"] = ApiResponse(
-        200,
-        [
-            {
-                "id": 44,
-                "name": "evaluating release gate",
-                "target": "branch",
-                "enforcement": "evaluate",
-                "conditions": {"ref_name": {"include": ["refs/heads/main"], "exclude": []}},
-            }
-        ],
-    )
-    responses["/repos/acme/clarion/rulesets/44"] = ApiResponse(
-        200,
-        {
-            "id": 44,
-            "name": "evaluating release gate",
-            "target": "branch",
-            "enforcement": "evaluate",
-            "conditions": {"ref_name": {"include": ["refs/heads/main"], "exclude": []}},
-            "rules": [
-                {"type": "pull_request", "parameters": {"required_approving_review_count": 0}},
-                {
-                    "type": "required_status_checks",
-                    "parameters": {
-                        "required_status_checks": [
-                            {"context": "Rust"},
-                            {"context": "Rust (aarch64-apple-darwin)"},
-                            {"context": "Python plugin"},
-                            {"context": "Sprint 1 walking skeleton (end-to-end)"},
-                        ]
-                    },
-                },
-            ],
-        },
-    )
-    try:
-        check_governance(fake_get, "acme/clarion", "main")
-    except CheckError as exc:
-        assert "no branch protection" in str(exc)
-    else:
-        raise AssertionError("evaluate-mode ruleset fixture should fail")
-
-    responses["/repos/acme/clarion/rulesets"] = ApiResponse(
-        200,
-        [
-            {
-                "id": 43,
-                "name": "main release gate",
-                "target": "branch",
-                "enforcement": "active",
-                "conditions": {"ref_name": {"include": ["refs/heads/main"], "exclude": []}},
-            },
-            {
-                "id": 50,
-                "name": "release tag gate",
-                "target": "tag",
-                "enforcement": "active",
-                "conditions": {"ref_name": {"include": ["refs/tags/v*"], "exclude": []}},
-            },
-        ],
-    )
-    responses["/repos/acme/clarion/rulesets/43"] = ApiResponse(
-        200,
-        {
-            "id": 43,
-            "name": "main release gate",
-            "target": "branch",
-            "enforcement": "active",
-            "conditions": {"ref_name": {"include": ["refs/heads/main"], "exclude": []}},
-            "rules": [
-                {"type": "pull_request", "parameters": {"required_approving_review_count": 0}},
-                {
-                    "type": "required_status_checks",
-                    "parameters": {
-                        "required_status_checks": [
-                            {"context": "Rust"},
-                            {"context": "Rust (aarch64-apple-darwin)"},
-                            {"context": "Python plugin"},
-                            {"context": "Sprint 1 walking skeleton (end-to-end)"},
-                        ]
-                    },
-                },
-            ],
-        },
-    )
-    notes = check_governance(fake_get, "acme/clarion", "main")
-    assert any("active ruleset 'main release gate'" in note for note in notes)
-    assert any("active tag ruleset(s) protect refs/tags/v*" in note for note in notes)
-
-    # Tag protection absent (GOV-02): a fully-protected branch but no tag
-    # ruleset must still fail — a tag-push could point a release tag at an
-    # arbitrary commit.
-    responses["/repos/acme/clarion/rulesets"] = ApiResponse(
-        200,
-        [
-            {
-                "id": 43,
-                "name": "main release gate",
-                "target": "branch",
-                "enforcement": "active",
-                "conditions": {"ref_name": {"include": ["refs/heads/main"], "exclude": []}},
-            }
-        ],
-    )
-    try:
-        check_governance(fake_get, "acme/clarion", "main")
-    except CheckError as exc:
-        assert "no active repository ruleset protects refs/tags/v*" in str(exc)
-    else:
-        raise AssertionError("missing tag ruleset fixture should fail")
-
-    with tempfile.TemporaryDirectory() as tmp:
-        root = Path(tmp)
-        workflow = root / ".github" / "workflows" / "ci.yml"
-        workflow.parent.mkdir(parents=True)
-        workflow.write_text(
-            "jobs:\n"
-            "  ok:\n"
-            "    steps:\n"
-            "      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5\n",
-            encoding="utf-8",
-        )
-        dependabot = root / ".github" / "dependabot.yml"
-        dependabot.write_text(
-            'version: 2\n'
-            'updates:\n'
-            '  - package-ecosystem: "github-actions"\n'
-            '    directory: "/"\n'
-            '    schedule:\n'
-            '      interval: "weekly"\n',
-            encoding="utf-8",
-        )
-        assert check_workflow_action_pins(root)
-        assert check_dependabot_github_actions_updates(root)
-        workflow.write_text(
-            "jobs:\n"
-            "  bad:\n"
-            "    steps:\n"
-            "      - uses: actions/checkout@v4\n",
-            encoding="utf-8",
-        )
-        try:
-            check_workflow_action_pins(root)
-        except CheckError as exc:
-            assert "not a full commit SHA" in str(exc)
-        else:
-            raise AssertionError("tag-pinned fixture should fail")
-        dependabot.write_text(
-            'version: 2\n'
-            'updates:\n'
-            '  - package-ecosystem: "pip"\n'
-            '    directory: "/plugins/python"\n',
-            encoding="utf-8",
-        )
-        try:
-            check_dependabot_github_actions_updates(root)
-        except CheckError as exc:
-            assert "missing package-ecosystem=github-actions" in str(exc)
-        else:
-            raise AssertionError("missing github-actions Dependabot fixture should fail")
-
-        release_workflow = workflow.parent / "release.yml"
-        release_workflow.write_text(
-            "jobs:\n"
-            "  verify:\n"
-            "    steps: []\n"
-            "  release-governance:\n"
-            "    steps: []\n"
-            "  build-rust:\n"
-            "    needs: [verify]\n"
-            "    steps: []\n"
-            "  build-plugin:\n"
-            "    needs: [verify]\n"
-            "    steps: []\n",
-            encoding="utf-8",
-        )
-        try:
-            check_release_workflow_governance_gate(root)
-        except CheckError as exc:
-            assert "build-rust" in str(exc)
-            assert "release-governance" in str(exc)
-        else:
-            raise AssertionError("ungated release-build fixture should fail")
-
-        release_workflow.write_text(
-            "jobs:\n"
-            "  verify:\n"
-            "    steps: []\n"
-            "  release-governance:\n"
-            "    steps: []\n"
-            "  build-rust:\n"
-            "    needs: [verify, release-governance]\n"
-            "    steps: []\n"
-            "  build-plugin:\n"
-            "    needs: [verify, release-governance]\n"
-            "    steps: []\n",
-            encoding="utf-8",
-        )
-        assert check_release_workflow_governance_gate(root)
-
-    repo_root = Path(__file__).resolve().parents[1]
-    assert check_governance_doc_lockstep(repo_root)
-
-    print("GitHub release governance guard self-test passed")
-
-
-def resolve_token() -> str | None:
-    token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
-    if token:
-        return token
-    proc = subprocess.run(
-        ["gh", "auth", "token"],
-        check=False,
-        stdout=subprocess.PIPE,
-        stderr=subprocess.DEVNULL,
-        text=True,
-    )
-    if proc.returncode == 0 and proc.stdout.strip():
-        return proc.stdout.strip()
-    return None
-
-
-def main(argv: list[str]) -> int:
-    parser = argparse.ArgumentParser(description=__doc__)
-    parser.add_argument(
-        "--repository",
-        default=os.environ.get("GITHUB_REPOSITORY"),
-        help="owner/repo to inspect; defaults to GITHUB_REPOSITORY",
-    )
-    parser.add_argument("--branch", default="main", help="release branch to inspect")
-    parser.add_argument(
-        "--repo-root",
-        type=Path,
-        default=Path.cwd(),
-        help="repository root for static workflow-pin checks",
-    )
-    parser.add_argument(
-        "--api-base",
-        default=os.environ.get("GITHUB_API_URL", "https://api.github.com"),
-        help="GitHub API base URL",
-    )
-    parser.add_argument("--self-test", action="store_true", help="run built-in tests")
-    parser.add_argument(
-        "--static-only",
-        action="store_true",
-        help="run source-tree checks and skip live GitHub API checks",
-    )
-    args = parser.parse_args(argv)
-
-    if args.self_test:
-        run_self_test()
-        return 0
-
-    try:
-        static_notes = [
-            *check_workflow_action_pins(args.repo_root),
-            *check_dependabot_github_actions_updates(args.repo_root),
-            *check_release_workflow_governance_gate(args.repo_root),
-            *check_governance_doc_lockstep(args.repo_root),
-        ]
-    except CheckError as exc:
-        print("GitHub release governance guard failed:", file=sys.stderr)
-        print(str(exc), file=sys.stderr)
-        return 1
-    except UsageError as exc:
-        print(f"GitHub release governance guard could not run: {exc}", file=sys.stderr)
-        return 2
-
-    if args.static_only:
-        for note in static_notes:
-            print(f"ok: {note}")
-        return 0
-
-    token = resolve_token()
-    if not args.repository:
-        print(
-            "check-github-release-governance: --repository or GITHUB_REPOSITORY is required",
-            file=sys.stderr,
-        )
-        return 2
-    if not token:
-        print(
-            "check-github-release-governance: GITHUB_TOKEN or GH_TOKEN is required",
-            file=sys.stderr,
-        )
-        return 2
-
-    client = GitHubClient(token=token, api_base=args.api_base)
-    try:
-        notes = [*static_notes, *check_governance(client.get, args.repository, args.branch)]
-    except CheckError as exc:
-        print("GitHub release governance guard failed:", file=sys.stderr)
-        print(str(exc), file=sys.stderr)
-        return 1
-    except UsageError as exc:
-        print(f"GitHub release governance guard could not run: {exc}", file=sys.stderr)
-        return 2
-
-    for note in notes:
-        print(f"ok: {note}")
-    return 0
-
-
-if __name__ == "__main__":
-    raise SystemExit(main(sys.argv[1:]))

From 1234447a7bb49e4b5b411398835e0a99818f626e Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 05:28:25 +1000
Subject: [PATCH 13/21] Stabilize serve HTTP tests under nextest

---
 .config/nextest.toml              |  6 ++++
 crates/clarion-cli/tests/serve.rs | 52 +++++++++++++++++++++++++++++--
 2 files changed, 56 insertions(+), 2 deletions(-)
 create mode 100644 .config/nextest.toml

diff --git a/.config/nextest.toml b/.config/nextest.toml
new file mode 100644
index 00000000..e7d60791
--- /dev/null
+++ b/.config/nextest.toml
@@ -0,0 +1,6 @@
+[test-groups]
+serve-http = { max-threads = 1 }
+
+[[profile.default.overrides]]
+filter = 'package(clarion-cli) and binary(=serve)'
+test-group = 'serve-http'
diff --git a/crates/clarion-cli/tests/serve.rs b/crates/clarion-cli/tests/serve.rs
index 718a8e17..9e47bfdf 100644
--- a/crates/clarion-cli/tests/serve.rs
+++ b/crates/clarion-cli/tests/serve.rs
@@ -4,7 +4,7 @@ use std::net::{TcpListener, TcpStream};
 use std::os::unix::fs::PermissionsExt;
 use std::path::Path;
 use std::process::{Child, Command as StdCommand, Stdio};
-use std::sync::mpsc;
+use std::sync::{LazyLock, Mutex, mpsc};
 use std::thread;
 use std::time::{Duration, Instant};
 
@@ -15,11 +15,14 @@ use clarion_core::{
 };
 use hmac::{Hmac, Mac};
 use rusqlite::{Connection, params};
+use serde::Deserialize;
 use serde_json::Value;
 use sha2::{Digest, Sha256};
 use uuid::Uuid;
 
 const STABLE_INSTANCE_ID: &str = "9bd7234e-6d44-4a38-9ae4-76f912a10221";
+static RESERVED_LOOPBACK_BINDS: LazyLock>> =
+    LazyLock::new(|| Mutex::new(Vec::new()));
 
 #[derive(Debug)]
 struct HttpJsonResponse {
@@ -2579,7 +2582,51 @@ fn seed_storage_failure_file_entity(project_root: &Path) {
 
 fn free_loopback_bind() -> String {
     let listener = TcpListener::bind("127.0.0.1:0").expect("bind free loopback port");
-    listener.local_addr().expect("local addr").to_string()
+    let bind = listener.local_addr().expect("local addr").to_string();
+    RESERVED_LOOPBACK_BINDS
+        .lock()
+        .expect("reserved loopback bind lock")
+        .push((bind.clone(), listener));
+    bind
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+struct ServeTestConfig {
+    serve: ServeTestSection,
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+struct ServeTestSection {
+    http: ServeHttpTestSection,
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+struct ServeHttpTestSection {
+    bind: Option,
+}
+
+fn release_reserved_loopback_bind(project_root: &Path) {
+    let Some(bind) = configured_http_bind(project_root) else {
+        return;
+    };
+    let mut reserved = RESERVED_LOOPBACK_BINDS
+        .lock()
+        .expect("reserved loopback bind lock");
+    if let Some(index) = reserved
+        .iter()
+        .position(|(reserved_bind, _)| reserved_bind == &bind)
+    {
+        reserved.swap_remove(index);
+    }
+}
+
+fn configured_http_bind(project_root: &Path) -> Option {
+    let raw = fs::read_to_string(project_root.join("clarion.yaml")).ok()?;
+    let parsed: ServeTestConfig = serde_norway::from_str(&raw).ok()?;
+    parsed.serve.http.bind
 }
 
 fn write_stdio_config(project_root: &Path) {
@@ -2735,6 +2782,7 @@ fn spawn_serve(project_root: &Path) -> ServeChild {
 }
 
 fn spawn_serve_with_env(project_root: &Path, env: &[(&str, &str)]) -> ServeChild {
+    release_reserved_loopback_bind(project_root);
     let mut command = StdCommand::new(assert_cmd::cargo::cargo_bin("clarion"));
     command
         .args(["serve", "--path"])

From de51c48259b9cba1ce6fa84b6ea79a8eb1178ceb Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 05:41:52 +1000
Subject: [PATCH 14/21] chore(arch): Archive older architectural documentation

---
 crates/clarion-storage/src/writer.rs          | 18 ++++----
 crates/clarion-storage/tests/schema_apply.rs  | 41 +++++++++++++++++++
 .../00-coordination.md                        |  0
 .../01-discovery-findings.md                  |  0
 .../02-subsystem-catalog.md                   |  0
 .../03-diagrams.md                            |  0
 .../04-final-report.md                        |  0
 .../00-coordination.md                        |  0
 .../01-discovery-findings.md                  |  0
 .../02-subsystem-catalog.md                   |  0
 .../03-diagrams.md                            |  0
 .../04-final-report.md                        |  0
 .../05-quality-assessment.md                  |  0
 .../06-architect-handover.md                  |  0
 14 files changed, 51 insertions(+), 8 deletions(-)
 rename docs/{ => archive}/arch-analysis-2026-05-22-1924/00-coordination.md (100%)
 rename docs/{ => archive}/arch-analysis-2026-05-22-1924/01-discovery-findings.md (100%)
 rename docs/{ => archive}/arch-analysis-2026-05-22-1924/02-subsystem-catalog.md (100%)
 rename docs/{ => archive}/arch-analysis-2026-05-22-1924/03-diagrams.md (100%)
 rename docs/{ => archive}/arch-analysis-2026-05-22-1924/04-final-report.md (100%)
 rename docs/{ => archive}/arch-analysis-2026-06-02-1522/00-coordination.md (100%)
 rename docs/{ => archive}/arch-analysis-2026-06-02-1522/01-discovery-findings.md (100%)
 rename docs/{ => archive}/arch-analysis-2026-06-02-1522/02-subsystem-catalog.md (100%)
 rename docs/{ => archive}/arch-analysis-2026-06-02-1522/03-diagrams.md (100%)
 rename docs/{ => archive}/arch-analysis-2026-06-02-1522/04-final-report.md (100%)
 rename docs/{ => archive}/arch-analysis-2026-06-02-1522/05-quality-assessment.md (100%)
 rename docs/{ => archive}/arch-analysis-2026-06-02-1522/06-architect-handover.md (100%)

diff --git a/crates/clarion-storage/src/writer.rs b/crates/clarion-storage/src/writer.rs
index d777645f..0755c1c5 100644
--- a/crates/clarion-storage/src/writer.rs
+++ b/crates/clarion-storage/src/writer.rs
@@ -12,6 +12,7 @@
 //! present in release builds as a no-op counter; no `#[cfg(test)]` gating
 //! is used.
 
+use std::path::Path;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicUsize, Ordering};
 
@@ -71,10 +72,18 @@ impl Writer {
     /// Returns [`StorageError::Sqlite`] if the `rusqlite::Connection` cannot
     /// be opened, or [`StorageError::PragmaInvariant`] if write PRAGMAs fail.
     pub fn spawn(
-        db_path: std::path::PathBuf,
+        db_path: impl AsRef,
         batch_size: usize,
         channel_capacity: usize,
     ) -> Result<(Self, JoinHandle>)> {
+        let mut conn = Connection::open(db_path.as_ref())?;
+        pragma::apply_write_pragmas(&conn)?;
+        // STO-02: refuse a database whose `user_version` is strictly greater
+        // than CURRENT_SCHEMA_VERSION. Equal/less are normal — equal is the
+        // already-migrated steady state, less is handled by the migration
+        // runner (which `install` calls before the writer ever spawns).
+        schema::verify_user_version(&conn)?;
+
         let (tx, rx) = mpsc::channel(channel_capacity);
         let commits_observed = Arc::new(AtomicUsize::new(0));
         let dropped_edges_total = Arc::new(AtomicUsize::new(0));
@@ -83,13 +92,6 @@ impl Writer {
         let dropped_for_actor = dropped_edges_total.clone();
         let ambiguous_for_actor = ambiguous_edges_total.clone();
         let handle = tokio::task::spawn_blocking(move || -> Result<()> {
-            let mut conn = Connection::open(&db_path)?;
-            pragma::apply_write_pragmas(&conn)?;
-            // STO-02: refuse a database whose `user_version` is strictly greater
-            // than CURRENT_SCHEMA_VERSION. Equal/less are normal — equal is the
-            // already-migrated steady state, less is handled by the migration
-            // runner (which `install` calls before the writer ever spawns).
-            schema::verify_user_version(&conn)?;
             run_actor(
                 rx,
                 &mut conn,
diff --git a/crates/clarion-storage/tests/schema_apply.rs b/crates/clarion-storage/tests/schema_apply.rs
index 41be4320..42894f22 100644
--- a/crates/clarion-storage/tests/schema_apply.rs
+++ b/crates/clarion-storage/tests/schema_apply.rs
@@ -1195,6 +1195,47 @@ fn open_refuses_db_from_future_user_version() {
     );
 }
 
+#[test]
+fn writer_spawn_refuses_future_user_version_before_returning_sender() {
+    let tempdir = tempfile::tempdir().unwrap();
+    let path = tempdir.path().join("future-sync.db");
+    {
+        let mut conn = Connection::open(&path).unwrap();
+        pragma::apply_write_pragmas(&conn).unwrap();
+        schema::apply_migrations(&mut conn).unwrap();
+    }
+    {
+        let conn = Connection::open(&path).unwrap();
+        conn.execute_batch(&format!(
+            "PRAGMA user_version = {};",
+            schema::CURRENT_SCHEMA_VERSION + 1
+        ))
+        .expect("bump user_version");
+    }
+
+    let expected_found = schema::CURRENT_SCHEMA_VERSION + 1;
+    let expected_current = schema::CURRENT_SCHEMA_VERSION;
+    let rt = tokio::runtime::Builder::new_multi_thread()
+        .worker_threads(2)
+        .enable_all()
+        .build()
+        .expect("tokio runtime");
+    rt.block_on(async move {
+        match Writer::spawn(path, 50, 256) {
+            Err(StorageError::FutureUserVersion { found, current }) => {
+                assert_eq!(found, expected_found);
+                assert_eq!(current, expected_current);
+            }
+            Err(err) => panic!("expected FutureUserVersion, got {err:?}"),
+            Ok((writer, handle)) => {
+                drop(writer);
+                handle.abort();
+                panic!("Writer::spawn returned a sender for a future-versioned database");
+            }
+        }
+    });
+}
+
 #[test]
 fn open_sets_application_id_on_legacy_db() {
     let tempdir = tempfile::tempdir().unwrap();
diff --git a/docs/arch-analysis-2026-05-22-1924/00-coordination.md b/docs/archive/arch-analysis-2026-05-22-1924/00-coordination.md
similarity index 100%
rename from docs/arch-analysis-2026-05-22-1924/00-coordination.md
rename to docs/archive/arch-analysis-2026-05-22-1924/00-coordination.md
diff --git a/docs/arch-analysis-2026-05-22-1924/01-discovery-findings.md b/docs/archive/arch-analysis-2026-05-22-1924/01-discovery-findings.md
similarity index 100%
rename from docs/arch-analysis-2026-05-22-1924/01-discovery-findings.md
rename to docs/archive/arch-analysis-2026-05-22-1924/01-discovery-findings.md
diff --git a/docs/arch-analysis-2026-05-22-1924/02-subsystem-catalog.md b/docs/archive/arch-analysis-2026-05-22-1924/02-subsystem-catalog.md
similarity index 100%
rename from docs/arch-analysis-2026-05-22-1924/02-subsystem-catalog.md
rename to docs/archive/arch-analysis-2026-05-22-1924/02-subsystem-catalog.md
diff --git a/docs/arch-analysis-2026-05-22-1924/03-diagrams.md b/docs/archive/arch-analysis-2026-05-22-1924/03-diagrams.md
similarity index 100%
rename from docs/arch-analysis-2026-05-22-1924/03-diagrams.md
rename to docs/archive/arch-analysis-2026-05-22-1924/03-diagrams.md
diff --git a/docs/arch-analysis-2026-05-22-1924/04-final-report.md b/docs/archive/arch-analysis-2026-05-22-1924/04-final-report.md
similarity index 100%
rename from docs/arch-analysis-2026-05-22-1924/04-final-report.md
rename to docs/archive/arch-analysis-2026-05-22-1924/04-final-report.md
diff --git a/docs/arch-analysis-2026-06-02-1522/00-coordination.md b/docs/archive/arch-analysis-2026-06-02-1522/00-coordination.md
similarity index 100%
rename from docs/arch-analysis-2026-06-02-1522/00-coordination.md
rename to docs/archive/arch-analysis-2026-06-02-1522/00-coordination.md
diff --git a/docs/arch-analysis-2026-06-02-1522/01-discovery-findings.md b/docs/archive/arch-analysis-2026-06-02-1522/01-discovery-findings.md
similarity index 100%
rename from docs/arch-analysis-2026-06-02-1522/01-discovery-findings.md
rename to docs/archive/arch-analysis-2026-06-02-1522/01-discovery-findings.md
diff --git a/docs/arch-analysis-2026-06-02-1522/02-subsystem-catalog.md b/docs/archive/arch-analysis-2026-06-02-1522/02-subsystem-catalog.md
similarity index 100%
rename from docs/arch-analysis-2026-06-02-1522/02-subsystem-catalog.md
rename to docs/archive/arch-analysis-2026-06-02-1522/02-subsystem-catalog.md
diff --git a/docs/arch-analysis-2026-06-02-1522/03-diagrams.md b/docs/archive/arch-analysis-2026-06-02-1522/03-diagrams.md
similarity index 100%
rename from docs/arch-analysis-2026-06-02-1522/03-diagrams.md
rename to docs/archive/arch-analysis-2026-06-02-1522/03-diagrams.md
diff --git a/docs/arch-analysis-2026-06-02-1522/04-final-report.md b/docs/archive/arch-analysis-2026-06-02-1522/04-final-report.md
similarity index 100%
rename from docs/arch-analysis-2026-06-02-1522/04-final-report.md
rename to docs/archive/arch-analysis-2026-06-02-1522/04-final-report.md
diff --git a/docs/arch-analysis-2026-06-02-1522/05-quality-assessment.md b/docs/archive/arch-analysis-2026-06-02-1522/05-quality-assessment.md
similarity index 100%
rename from docs/arch-analysis-2026-06-02-1522/05-quality-assessment.md
rename to docs/archive/arch-analysis-2026-06-02-1522/05-quality-assessment.md
diff --git a/docs/arch-analysis-2026-06-02-1522/06-architect-handover.md b/docs/archive/arch-analysis-2026-06-02-1522/06-architect-handover.md
similarity index 100%
rename from docs/arch-analysis-2026-06-02-1522/06-architect-handover.md
rename to docs/archive/arch-analysis-2026-06-02-1522/06-architect-handover.md

From e4b9b363bf00b0ea7d48a2c491afe30cf99b9690 Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 05:50:39 +1000
Subject: [PATCH 15/21] docs: point federation-pattern docs at the loom hub
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The loom federation hub (~/loom) is now the single authoritative source
for federation-wide interoperability. Repoint Clarion's federation-PATTERN
content at the hub while preserving all Clarion-owned authoritative surface
(ADRs, route/contract specs).

- docs/suite/loom.md: founding doctrine promoted to ~/loom/doctrine.md;
  replace body with a pointer stub (section numbers preserved so `loom.md §N`
  refs still resolve). Note the roster is now 5 members + Shuttle thought-bubble,
  superseding the old three-member §1/§9 framing. Keep one Clarion-local note
  (HTTP read-API operator-trust pointer).
- docs/suite/glossary.md: promoted to ~/loom/glossary.md; replace body with a
  pointer stub; keep title and the Clarion-ADR authority note.
- docs/suite/briefing.md: keep as Clarion's intro; add a pointer to
  ~/loom/doctrine.md as the authoritative axiom/roster/composition-law source.
- docs/suite/README.md: add a line pointing to ~/loom as the authoritative
  federation hub.
- docs/federation/contracts.md: keep all Clarion-owned endpoint specs untouched;
  add a preamble pointer to ~/loom/doctrine.md (axiom) and
  ~/loom/contracts-index.md (contract index). No endpoint spec changed.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 docs/federation/contracts.md |  11 +++
 docs/suite/README.md         |   2 +
 docs/suite/briefing.md       |   2 +
 docs/suite/glossary.md       | 133 ++++-----------------------
 docs/suite/loom.md           | 170 +++++++++--------------------------
 5 files changed, 75 insertions(+), 243 deletions(-)

diff --git a/docs/federation/contracts.md b/docs/federation/contracts.md
index a80073ab..fa11a609 100644
--- a/docs/federation/contracts.md
+++ b/docs/federation/contracts.md
@@ -13,6 +13,17 @@ own semantics never depend on a taint fact existing. Every consume-side coupling
 here is likewise enrich-only and fail-soft — Clarion stays solo-useful when
 Filigree is absent (loom.md §5).
 
+> **Federation-pattern sources (pointers).** The federation axiom and composition
+> law cited as `loom.md §5` throughout this document are now authoritative at the
+> federation hub: [`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md) §5
+> (as of 2026-06-05; `loom.md` is now a pointer stub that preserves the original
+> section numbers, so `loom.md §5` resolves there). The cross-product **contract
+> index** — the suite-level list of every live cross-product contract and its
+> owning authority — lives at
+> [`~/loom/contracts-index.md`](file:///home/john/loom/contracts-index.md). The
+> endpoint specs below are **Clarion-owned and authoritative**; the hub indexes
+> them, it does not restate them.
+
 ## HTTP Read API
 
 `clarion serve` can expose the HTTP read API when enabled in `clarion.yaml`:
diff --git a/docs/suite/README.md b/docs/suite/README.md
index b2810465..007ac2ae 100644
--- a/docs/suite/README.md
+++ b/docs/suite/README.md
@@ -2,6 +2,8 @@
 
 This folder holds the Loom-wide documents.
 
+> **Authoritative federation hub:** [`~/loom`](file:///home/john/loom) is the single authoritative source for federation-wide interoperability — the doctrine ([`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md)), the cross-product glossary ([`~/loom/glossary.md`](file:///home/john/loom/glossary.md)), the SEI standard, the federation map, and the contract index. The `loom.md` and `glossary.md` files in this folder are now pointer stubs to the hub (promoted 2026-06-05). Clarion-owned docs (ADRs, federation contracts) remain authoritative in this repo.
+
 ## Canonical docs
 
 - [loom.md](./loom.md) — the suite doctrine: bounded authority, federation, and the composition law.
diff --git a/docs/suite/briefing.md b/docs/suite/briefing.md
index e5acaea3..41a843c4 100644
--- a/docs/suite/briefing.md
+++ b/docs/suite/briefing.md
@@ -10,6 +10,8 @@
 
 **Loom** is a suite for enterprise-grade code governance on small teams. Its v0.1 products — **Clarion**, **Filigree**, and **Wardline** — are three independent tools that enrich one another through narrow additive protocols. Each is fully authoritative in its domain and fully usable on its own. Clarion builds a trustworthy catalog of a codebase and answers structural questions. Filigree tracks the issues, findings, and observations that arise from examining that codebase. Wardline declares and enforces the trust topology that constrains how code is allowed to behave. Together they deliver rigor that normally requires enterprise-scale platform teams — without the operational weight, and without any shared runtime, store, or orchestrator. A fourth product, **Shuttle**, is proposed for transactional scoped change execution; see [loom.md](./loom.md) for the suite's founding doctrine, the enrichment-not-load-bearing principle, and the go/no-go test that governs future products.
 
+> **Authoritative source.** The Loom federation axiom, roster, and composition law are now authoritative at the federation hub: [`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md) (as of 2026-06-05). The hub's canonical roster is **5 realized members** — Clarion, Filigree, Wardline, **Legis**, and **Charter** — plus **Shuttle as a roadmap thought-bubble**; the three-member v0.1 framing in this briefing predates Legis and Charter and is kept as Clarion's local intro. This briefing remains Clarion's own introduction to the suite as Clarion sees it.
+
 ---
 
 ## The Loom products
diff --git a/docs/suite/glossary.md b/docs/suite/glossary.md
index 52955342..4e204faf 100644
--- a/docs/suite/glossary.md
+++ b/docs/suite/glossary.md
@@ -1,123 +1,24 @@
 # Loom suite glossary
 
-**Audience**: anyone designing or reviewing a cross-product-visible field name, ADR, or wire-shape change in any Loom product
-**Purpose**: a single read-only catalogue of terms whose meaning crosses product boundaries, so the same word never silently means two things in the federation
-**Companion**: [loom.md](./loom.md) for the federation axiom this glossary defends
+> **This glossary has been promoted to the Loom federation hub.**
+> The canonical, authoritative cross-product vocabulary catalogue — every term
+> whose meaning crosses product boundaries, with its managed/renamed/no-clash
+> verdict and authority — now lives at
+> **[`~/loom/glossary.md`](file:///home/john/loom/glossary.md)** (authoritative as of 2026-06-05).
+> This file is retained as a **pointer stub** so existing references resolve.
 
 ---
 
-## How to use this glossary
+The body of this file (the how-to-use guidance, the ADR-acceptance rule, the
+status legend, and the cross-product term tables — managed clashes, renamed
+clashes, no-clash informational entries, the SP9 Wardline taint-store wire terms,
+deferred clashes, Wardline-side terms, and the Shuttle note) is now authoritative
+at [`~/loom/glossary.md`](file:///home/john/loom/glossary.md). Read and update it
+there.
 
-This file is a **design-review artifact**, not infrastructure. Nothing imports it, nothing runs from it, and removing it changes no product's semantics — it is the same shape as `loom.md` itself. Per `loom.md` §5, this means the glossary is federation-safe: it does not introduce semantic coupling, initialization coupling, or pipeline coupling between siblings.
+The federation axiom this glossary defends is the cross-product field-name rule
+in the hub doctrine: [`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md) §8.
 
-**Consult this glossary when**:
-
-- Authoring an ADR that introduces or renames a cross-product-visible field name
-- Reviewing a wire-format change that adds a new top-level key
-- Onboarding to a Loom product after working on another, to surface vocabulary surprises
-- Triaging a bug whose framing depends on what a word means (the trigger that produced this glossary was exactly such a triage — see [skeleton-audit](../implementation/handoffs/2026-05-03-skeleton-audit.md))
-
-**Update this glossary when**:
-
-- An ADR moves a term from `open` to `managed` (note the ADR ID in the Authority column)
-- A new cross-product term is introduced (add a row before the ADR is Accepted; see ADR-acceptance rule below)
-- A term retires from cross-product visibility (mark `retired` with the retirement ADR ID, do not delete)
-
-**Do not** add CI lint, repo gate, or runtime check that consumes this file. Per `loom.md` §5, that would convert a federation-safe doc into shared infrastructure. The glossary is consulted by humans during design review; that is its only job.
-
-## ADR-acceptance rule
-
-ADRs introducing cross-product-visible field names must update this glossary before moving from Proposed to Accepted, with one of three explicit verdicts:
-
-- **`no clash`** — the term is unique to this product, no sibling currently uses it
-- **`managed clash`** — a sibling uses the same term; an explicit mapping table exists in the ADR (model: ADR-017's severity vocabulary table with `metadata.clarion.internal_severity` round-trip slot)
-- **`renamed`** — the proposed term clashed with a sibling; this ADR renames the local term to avoid the clash
-
-A vocabulary verdict is part of ADR-acceptance evidence, not a courtesy. Three of Clarion v0.1's clashes (`severity`, `rule_id`, `finding` wire shape) got managing ADRs at design time and shipped clean. Three did not (`priority`, `critical`, `source`) and required retrofit. This rule converts the next clash from "discovered during implementation" to "blocked at design review."
-
-## Status legend
-
-| Status | Meaning |
-|---|---|
-| `managed` | Same term used by ≥2 products; an Accepted ADR provides explicit mapping or namespacing |
-| `renamed` | Was a clash; an Accepted ADR renamed the local term to avoid the collision; the cross-product collision is gone |
-| `open` | Same term used by ≥2 products; **no managing ADR yet** — clash is live |
-| `no clash (informational)` | Term is unique to one product but listed here to head off cross-product reader confusion |
-| `deferred` | Clash exists; retirement condition documented; tracked elsewhere |
-| `retired` | Was a clash; retiring ADR named; kept as historical record |
-
-## Cross-product terms
-
-### Managed clashes
-
-| Term | Products | Semantics by product | Authority |
-|---|---|---|---|
-| `severity` | Clarion ↔ Filigree | Clarion internal: `INFO\|WARN\|ERROR\|CRITICAL` for defects, `NONE` for facts. Filigree wire: `critical\|high\|medium\|low\|info` (lowercase). | [ADR-017](../clarion/adr/ADR-017-severity-and-dedup.md) — explicit mapping table; `metadata.clarion.internal_severity` round-trip slot |
-| `rule_id` | Clarion + Wardline → Filigree | Namespaced prefix per emitter: `CLA-PY-*`, `CLA-INFRA-*`, `CLA-FACT-*`, `CLA-SEC-*`, `WLN-*`. Filigree stores byte-for-byte; round-trip preserved. | [ADR-017](../clarion/adr/ADR-017-severity-and-dedup.md), [ADR-022](../clarion/adr/ADR-022-core-plugin-ontology.md) — namespacing convention + grammar enforcement at the Clarion-plugin boundary |
-| `finding` (wire shape) | Clarion + Wardline → Filigree | Cross-product unified record type. Field ownership documented; extension via `metadata` slot (top-level keys outside the enumerated set are silently dropped). | [ADR-004](../clarion/adr/ADR-004-finding-exchange-format.md) — full wire schema with explicit ownership |
-
-### Renamed clashes (resolved by ADR-024 — see [skeleton-audit](../implementation/handoffs/2026-05-03-skeleton-audit.md))
-
-These entries record the resolution per the `renamed` verdict (see ADR-acceptance rule above). Each row names the pre-rename collision and the post-rename Clarion field name; Filigree's vocabulary is unchanged.
-
-| Term (post-rename) | Products | Resolution | Authority |
-|---|---|---|---|
-| `scope_level` (Clarion) ← was `priority` | Clarion ↔ Filigree | Clarion's guidance scope-of-applicability field is now `scope_level` (six-level string enum, semantics unchanged) plus a companion `scope_rank` integer (CASE-mapped 1..6) for `ORDER BY` queries. Filigree's `priority` (P0..P4) keeps its name. The shared word is gone. | [ADR-024](../clarion/adr/ADR-024-guidance-schema-vocabulary.md) |
-| `pinned` (Clarion) ← was `critical` | Clarion ↔ Filigree | Clarion's guidance budget-protection flag is now `pinned: bool` (semantics: preserved across token-budget pressure, unchanged). Filigree's `severity:critical` tier and informal "Critical" P0 label keep their meanings. | [ADR-024](../clarion/adr/ADR-024-guidance-schema-vocabulary.md) |
-| `provenance` (Clarion) ← was `source` (on `finding` and `guidance` only) | Within-Clarion + Clarion ↔ Filigree | Clarion's `finding.source` struct (`{tool, tool_version, run_id}`) is now `finding.provenance`; the `entity.properties.source` enum on guidance entities (`"manual"\|"wardline_derived"\|"filigree_promotion"`) is now `entity.properties.provenance`. `entity.source` (`SourceRange` on code entities) is unchanged — the type name disambiguates. Filigree's `source:` taxonomy label keeps its meaning. | [ADR-024](../clarion/adr/ADR-024-guidance-schema-vocabulary.md) |
-
-### No-clash informational entries
-
-| Term | Owning product | Note for cross-product readers |
-|---|---|---|
-| `tags` (Clarion) vs `labels` (Filigree) | both | Different word, similar concept. Clarion's `tags` are free-form (plugin/LLM-emitted); Filigree's `labels` are a curated namespaced taxonomy (`area:`, `cluster:`, `effort:`, `priority:`, …). The names accurately reflect the design difference. No rename. |
-| `kind` | Clarion (three uses) | Used three ways within Clarion: `entity.kind` (entity taxonomy), `edge.kind` (edge taxonomy), `finding.kind` (`defect\|fact\|classification\|metric\|suggestion`). Disambiguated by struct context; the type carries the namespace. Filigree uses `type` for the analogous concept on issues. |
-| `status` | Clarion + Filigree | Distinct state machines on distinct objects: Clarion `runs.status`, Clarion `findings.status` (`open\|acknowledged\|suppressed\|promoted_to_issue` per `detailed-design.md` §6.5; Filigree-side mapping in `detailed-design.md` §7), Filigree per-type issue state machines (`bug` has `triage→confirmed→fixing→...`). Always disambiguated by table or struct. |
-| `entity` | Clarion | Clarion code object (function, class, module, guidance, file, subsystem). Other products do not use this term. |
-| `SEI` (Stable Entity Identity) | Clarion (authority) → Wardline, Filigree, `legis` (consumers) | Durable, opaque surrogate identity for a code entity, minted and resolved by Clarion; the single key every cross-tool binding uses, stable across rename/move/edit. Single meaning suite-wide — `no clash`. Authority: SEI standard (`2026-06-01-loom-stable-entity-identity-conformance.md`) for the suite definition; [ADR-038](../clarion/adr/ADR-038-sei-token-and-signature.md) for Clarion's token form, persistence, and the reserved `clarion:eid:` namespace. Consumers MUST treat it opaque (do not parse). |
-| `locator` | Clarion (authority) → suite | The mutable address form `{plugin_id}:{kind}:{qualname}` (the pre-SEI entity id, demoted by [ADR-038](../clarion/adr/ADR-038-sei-token-and-signature.md) from *identity* to *address*). Resolvable to a current SEI; changes on rename/move. Single meaning suite-wide — `no clash`. |
-| `subsystem` | Clarion | Cluster of entities produced by Phase 3 clustering. Clarion-only. |
-| `briefing` | Clarion | Structured per-entity summary served to consult-mode agents. Clarion-only. |
-| `guidance sheet` | Clarion | Institutional knowledge attached to an entity. Clarion-only. |
-| `observation` | Filigree | Fire-and-forget agent note that expires after 14 days. Filigree-only. (Note: Clarion `clarion-` prefixed issue IDs may surface in observations, but `observation` as a record type is Filigree-owned.) |
-| `finding` (record vs. wire) | Clarion + Wardline (record); Filigree (wire) | Clarion and Wardline both produce `finding` records with internal vocabulary. The wire shape that crosses into Filigree is the managed-clash form documented above. Locally each product's `Finding` struct has product-specific fields beyond the wire schema. |
-| `run` / `run_id` | Clarion + Wardline | Each product has its own analyse/scan run lifecycle. The `run_id` field on a finding is namespaced by emitter (per `provenance.tool`); the strings are not assumed cross-product-meaningful. |
-
-### SP9 Wardline taint-store wire terms (ADR-036)
-
-These terms cross the Wardline↔Clarion wire in the SP9 taint-store contract (`/api/wardline/*` routes). All are `no clash`: each is either Wardline-namespaced or a field name unique to this Clarion surface, and none collides with an existing sibling term. Per the ADR-acceptance rule, recorded here as part of ADR-036's acceptance evidence. (The Clarion-internal table name `wardline_taint_facts` and config key `serve.http.wardline_taint_write` are deliberately omitted — they never cross the wire to Wardline.)
-
-| Term | Products | Semantics | Authority |
-|---|---|---|---|
-| `wardline_json` | Clarion ↔ Wardline | The taint/provenance fact blob. **Opaque to Clarion and Wardline-owned**: Clarion stores and returns it verbatim, never parses, validates, or depends on its contents. All taint semantics stay Wardline-side. | [ADR-036](../clarion/adr/ADR-036-wardline-taint-fact-store.md) — `no clash` |
-| `scan_id` | Clarion ↔ Wardline | Wardline's scan generation identifier for a taint fact, accepted as a queryable column for observability + an optional future prune-by-scan. Wardline-namespaced; not assumed cross-product-meaningful (cf. the `run`/`run_id` entry above). | [ADR-036](../clarion/adr/ADR-036-wardline-taint-fact-store.md) — `no clash` |
-| `content_hash_at_compute` | Clarion ↔ Wardline | The containing-file content hash Wardline recorded **at compute time** (whole-file `blake3`, hex — Clarion's existing definition). Stored as a queryable column; Wardline compares it against `current_content_hash` to decide freshness. | [ADR-036](../clarion/adr/ADR-036-wardline-taint-fact-store.md) — `no clash` |
-| `current_content_hash` | Clarion ↔ Wardline | The entity's containing-file content hash **as derived now** at read time (same whole-file `blake3` definition), returned on fetch. Match with `content_hash_at_compute` → fact is fresh; mismatch/absent → stale → Wardline recomputes. | [ADR-036](../clarion/adr/ADR-036-wardline-taint-fact-store.md) — `no clash` |
-| `unresolved_qualnames` | Clarion ↔ Wardline | The list of pre-composed qualnames a batch write could **not** resolve to an `exact` Clarion entity (heuristic/none are never written); returned so Wardline can fall back rather than guess. Distinct from the deferred L7-qualname-format clash below. | [ADR-036](../clarion/adr/ADR-036-wardline-taint-fact-store.md) — `no clash` |
-
-### Deferred clashes (tracked, not resolved)
-
-| Term | Products | Status | Tracked by |
-|---|---|---|---|
-| L7 qualname format | Clarion ↔ Wardline | Clarion's L7 emits combined dotted `module.qualified_name`; Wardline's `FingerprintEntry` stores `(module, qualified_name)` as separate fields. No semantic clash today (Sprint 1 does not join across this boundary); becomes load-bearing at WP9 (Loom integrations). | [ADR-018](../clarion/adr/ADR-018-identity-reconciliation.md) amendment trigger; filigree issue `clarion-889200006a` (sprint:2 / wp:9). Trigger: WP9 attempts the first cross-product join. |
-
-## Wardline-side terms (for cross-product reader benefit)
-
-These terms are owned by Wardline. Listed here so a Clarion or Filigree reader does not assume Clarion-side semantics.
-
-| Term | Wardline meaning |
-|---|---|
-| `Tier N` | Trust tier classification level applied to entities. Numeric. |
-| `annotation_group` / `wardline_group` | Group of related Wardline annotations sharing a tier or policy band. Used as a `match_rules.type` value in Clarion guidance sheets. |
-| `FingerprintEntry` | Wardline's storage object pairing `(module, qualified_name)`. See deferred clash above. |
-| `governed default` | Wardline policy concept: a default value declared as policy-governed (rule IDs like `PY-WL-001-GOVERNED-DEFAULT`). |
-
-## Shuttle (proposed)
-
-Shuttle is not in flight. When Shuttle's design begins, the first design-review pass against this glossary should add Shuttle's authoritative terms and explicitly check `change`, `apply`, `commit`, `rollback`, `transaction` against the existing Loom vocabulary surface.
-
-## History
-
-- **2026-05-03** — Glossary created during the v0.1 skeleton audit (Sprint 2 kickoff). Seeded with the three managed ADR-mediated clashes, the three open clashes resolved by ADR-024, the no-clash informational entries, and the deferred ADR-018 amendment trigger.
-- **2026-05-03** — ADR-024 Accepted; the `priority`/`critical`/`source` rows moved from `open` to `renamed` (see "Renamed clashes" section). Schema migration `0001_initial_schema.sql` edited in place per the policy named in ADR-024.
-- **2026-05-31** — ADR-036 Accepted; added the SP9 Wardline taint-store wire terms (`wardline_json`, `scan_id`, `content_hash_at_compute`, `current_content_hash`, `unresolved_qualnames`) as `no clash` informational entries, recorded as ADR-acceptance evidence.
+Clarion's ADRs (e.g. ADR-004, ADR-017, ADR-022, ADR-024, ADR-036, ADR-038)
+remain Clarion-owned and authoritative for Clarion's own field shapes; the hub
+glossary points to them, not the reverse.
diff --git a/docs/suite/loom.md b/docs/suite/loom.md
index ac446b43..f24ae048 100644
--- a/docs/suite/loom.md
+++ b/docs/suite/loom.md
@@ -1,132 +1,48 @@
 # Loom
 
-**Audience**: anyone designing, extending, or evaluating whether a new product belongs in the Loom family
-**Purpose**: establishes the strategic direction, composition law, and go/no-go test that govern Loom as a suite
-**Companion**: [briefing.md](./briefing.md) for an introductory 5-minute read
+> **This founding doctrine has been promoted to the Loom federation hub.**
+> The canonical, authoritative Loom federation doctrine — federation axiom,
+> roster, composition law, enrichment-not-load-bearing test, and the go/no-go
+> gate for new products — now lives at **[`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md)** (authoritative as of 2026-06-05).
+> This file is retained as a **pointer stub** so existing references resolve.
+>
+> The doctrine was promoted faithfully and **preserves the original section
+> numbers**, so any reference of the form `loom.md §N` (e.g. `loom.md §5` =
+> enrichment-not-load-bearing, `§7` = go/no-go, `§8` = naming) resolves to the
+> same content at `~/loom/doctrine.md §N`.
 
 ---
 
-## 1. What Loom is
-
-Loom is a suite for enterprise-grade code governance on small teams. Its first tools are **Clarion**, **Filigree**, and **Wardline**, each fully authoritative in its domain and fully usable on its own. When composed, they enrich one another through narrow, additive protocols — but each remains independently load-bearing for the work it already does. A fourth product, **Shuttle**, is proposed and not yet in flight.
-
-The metaphor is deliberate: distinct threads stay distinct but gain value by being woven together. Loom is a **family name** and a **composition doctrine** — not a platform, not a shared runtime, not a store, and not a broker. There is nothing called "Loom" to install, deploy, or keep running. What exists are the member products, and a set of narrow interop contracts between them.
-
-## 2. The products and their authoritative domains
-
-Each Loom product is authoritative for exactly one bounded concern, and that authority lives in the product itself — not in any shared layer:
-
-- **Clarion** — structural truth about the codebase. Answers "what is this codebase and where should I touch?" Owns the entity catalog, the code graph, and guidance sheets.
-- **Filigree** — work state and workflow lifecycle. Answers "what work exists, what state is it in, and what happened?" Owns issues, observations, and finding triage state.
-- **Wardline** — trust policy and rule enforcement. Answers "what is allowed, and does this still satisfy the declared constraints?" Owns trust declarations, baselines, and policy findings. Notably, Wardline's "configuration" is the source code itself plus the adjacent declarations — it does not have a separate authoritative config store.
-- **Shuttle** *(proposed)* — transactional scoped change execution. Answers "carry this approved change through the weave, under guard rails." Would own the execution record of applied changes and their rollback provenance.
-
-Shuttle's scope is deliberately narrow: it receives a scoped change intent, binds it to files or entities, orders the edits, applies them incrementally with pre- and post-change checks, rolls back on failure, and lints / commits / emits telemetry on success. It does not plan, triage, or reason about the code it is editing.
-
-## 3. Federation, not monolith
-
-**Loom is a federation, not a monolith. Each member product is authoritative in one bounded domain. Integration must be additive, not compulsory. No Loom product may require the full suite to justify its existence.**
-
-This is the founding architectural law. There is no Loom runtime, no Loom config layer, and no Loom store. Loom is a family name, a composition doctrine, and a set of narrow interop contracts — nothing more. The rule protects against the stealth-monolith failure mode: a "lightweight glue layer" that quietly becomes the real system of record, reducing sibling products to thin clients and making solo mode dishonest.
-
-## 4. The composition law
-
-Any Loom product must satisfy all three modes:
-
-- **Solo mode** — the product has a complete, respectable use-case by itself
-- **Pair mode** — combined with any one sibling, it creates a meaningful capability, not a broken fragment
-- **Suite mode** — all together form something richer, but suite mode must never be mandatory for basic usefulness
-
-Pairwise composability is a hard rule, not an aspiration. A product that only works when all siblings are present is a feature of a monolith wearing modular clothing.
-
-## 5. Enrichment, not load-bearing
-
-**A sibling product may enrich another product's view, but it must never be required for that product's semantics to make sense.**
-
-This is the rule that keeps integration additive. It has a concrete test and concrete consequences:
-
-### The failure test
-
-The principle has three failure modes. Any one of them means Loom has centralised too far:
-
-1. **Semantic coupling** — if removing a sibling product changes the *meaning* of another product's own data. Sibling absence may reduce convenience or automation; it must not alter semantics. Less capability is acceptable; incoherent data is not.
-2. **Initialization coupling** — if a product cannot start, self-test, or validate its own configuration without a sibling being present. The product may degrade its capabilities in the sibling's absence; it must not fail to boot.
-3. **Pipeline coupling** — if a pair of sibling products (X, Z) cannot exchange data except through a third sibling (Y). Each pair's ability to compose must be independent of any uninvolved third product — pairwise composability (§4) is not satisfied if the pair silently routes through an absent mediator.
-
-A "standalone mode" that works only because an invisible sibling is still imported, or a "pairwise mode" that actually routes through an absent mediator, is not federation.
-
-### Concrete examples
-
-- **Filigree** creates and closes tickets exactly the same way whether Clarion is installed or not. Clarion makes the tickets richer — entity context, file references, structural findings linked to issues — but doesn't change their meaning. You can file a bug, work it, and close it with Clarion absent or broken.
-- **Wardline** enforces trust policy whether Filigree is ingesting findings or not. Findings reach Wardline's own SARIF output regardless of whether a downstream triage system exists.
-- **Clarion** builds its catalog whether Wardline is present or not. Wardline's annotations *enrich* Clarion's entity metadata with trust-tier and policy-semantic information, but Clarion's structural truth is independent of Wardline's policy truth.
-- **Shuttle**, if built, would execute changes whether any sibling is present. Sibling tools enrich its telemetry (which Filigree ticket? which Clarion entity? which Wardline policy?) but are never required for a change to apply or roll back.
-
-### v1.0 asterisks
-
-The v1.0 suite does not pass the expanded failure test cleanly. Two specific couplings are named here so they cannot drift unnoticed:
-
-- **Wardline→Filigree findings are pipeline-coupled through Clarion in v1.0.** Wardline's SARIF output reaches Filigree only via Clarion's `clarion sarif import` translator. This violates pipeline composability for the (Wardline, Filigree) pair. *Retirement condition* (mechanism updated 2026-05-29): the generic Wardline rebuild ships a **native Filigree emitter** (Wardline-side, per the 2026-05-29 integration brief and Clarion's ADR-015 Revision 2), at which point the pair composes directly and Clarion drops off the transport path — its `clarion sarif import` translator stays as the general-purpose SARIF path for other tools; only its Wardline-bridge role retires. The asterisk is **kept live** until that emitter ships and (Wardline, Filigree) composition is verified with Clarion absent — agreement to the direction is not retirement. Tracked under `release:1.1`.
-- **Retired 2026-06-05 — Clarion's Python plugin imported `wardline.core.registry.REGISTRY` at startup.** This was initialization coupling scoped to the Wardline-aware plugin specifically, not to Clarion as a product. The retirement condition was met in two parts: Wardline shipped the NG-25 trust-vocabulary descriptor, and Clarion's Python plugin now reads that descriptor (`.wardline/vocabulary.yaml` first, then the installed `wardline/core/vocabulary.yaml` data file) without importing `wardline`, `wardline.core`, or `wardline.core.registry`. Clarion records only source-observed decorator metadata on its own entities; Wardline remains authoritative for vocabulary and policy semantics. See ADR-018 Revision 3. The asterisk is no longer live on the Clarion side.
-
-Asterisks are acceptable only with a written retirement condition and an honest statement of which failure-test mode is being temporarily violated. A "we'll fix it later" without a test-mode citation is not an asterisk; it is the stealth-monolith failure mode wearing different clothes.
-
-### Why this matters
-
-Enrichment is the shape of integration that preserves federation. Load-bearing integration collapses federation into monolith by another name. The moment one product *needs* another to make sense of its own data, the composition law becomes dishonest — "standalone mode" works only because the sibling is still running somewhere, and the illusion of modularity collapses the first time deployment doesn't match.
-
-## 6. What Loom is NOT
-
-Because the strongest pressure on this charter comes from "wouldn't it be easier if we just…" proposals, the disclaimer is explicit. Loom is **not**:
-
-- **A shared runtime or daemon.** There is no `loomd`, no broker, no orchestrator. Member products do not phone home to a Loom process.
-- **A shared configuration layer.** Each product configures its own integrations in its own config. Clarion's config names Filigree's endpoint directly; there is no central registry that everyone consults.
-- **A central store or database.** Each product owns its data locally. No shared SQLite/Postgres/object-store sits under the suite.
-- **A system of record for any cross-product state.** Finding lifecycle lives in Filigree. Entity identity lives in Clarion. Policy baselines live in Wardline. Execution provenance (if Shuttle ships) lives in Shuttle. Loom does not own or mirror these.
-- **An identity reconciliation service.** When cross-scheme translation is needed — e.g. Wardline qualname → Clarion entity ID — the product that *cares* does the translation, because that product is the one whose authority needs it. Clarion translates qualnames because Clarion owns the catalog that makes them meaningful. There is no neutral "Loom identity oracle."
-- **A capability negotiation bus.** Products probe each other directly via
-  their own surfaces (HTTP endpoints, MCP tools, CLI flags). Version skew is
-  handled bilaterally, not through a Loom-level registry. Clarion's HTTP read
-  API is one such bilateral surface; its operator trust model is documented in
-  [`docs/operator/clarion-http-read-api.md`](../operator/clarion-http-read-api.md):
-  the HTTP surface is unauthenticated, loopback-only by default, and requires an
-  authenticated reverse proxy or equivalent control before any non-loopback
-  bind.
-
-The test for any proposed addition: if the proposal introduces something that would need to be *running* or *present* for the suite to work, it violates federation. Integration protocols, schemas, and narrow contracts are fine. Shared infrastructure that sibling products *depend on* is not.
-
-## 7. The go/no-go test for future products
-
-Before adopting any new product into Loom, it must pass all four:
-
-1. **Is it authoritative for one narrowly bounded thing?** — if the scope is two or more things, it is two or more products.
-2. **Is it useful by itself?** — if siblings are required for minimum utility, it is a feature or adapter, not a product.
-3. **Does it form a sensible story with each existing product one-to-one?** — every pairing must yield a coherent workflow; no "this only matters when you also have X and Y" patterns.
-4. **Is the full suite better because of it, without making the others incomplete in its absence?** — addition, not patching.
-
-If the answer to any question is no, the candidate is a feature, a protocol, or an adapter — not a product. It may still belong in Loom's surface area, but not as a named member.
-
-## 8. Naming
-
-Member products are named from weaving mechanics — Clarion, Filigree, Wardline, Shuttle — as distinct proper names rather than subdivisions. There is no "Loom Guard," "Loom Workflow," or "Loom Execute"; each product earns its own identity. The family name sits above the products without dominating them, and — per §3 and §6 — it does not name any component that gets installed or runs.
-
-### Cross-product field names
-
-Federation does not require uniform vocabulary, but it does require that the same word never silently means two things across siblings. The discipline:
-
-- A single word should mean the same thing across products, OR be confined to one product, OR have an explicit mapping documented in an Accepted ADR.
-- The suite-level catalogue of cross-product-visible terms lives in [`glossary.md`](./glossary.md). It is a read-only design-review artefact (per §5: nothing imports it, nothing runs from it, removing it changes no semantics) and is federation-safe by construction.
-- Each product enforces the rule locally in its own ADR-acceptance process (Clarion's lives in [`docs/clarion/adr/README.md`](../clarion/adr/README.md)). Other products are expected to mirror the rule in their own ADR processes, citing this section as the suite-level authority. CI lint or cross-repo enforcement is explicitly out of bounds — that would convert a federation-safe doc into shared infrastructure.
-
-## 9. Status
-
-| Product | Status |
-|---|---|
-| Clarion | Built; v1.0 shipped against the published design (walking skeleton was tagged `v0.1-sprint-1`); first-customer target is elspeth (~425k LOC Python) |
-| Filigree | Built; in active use |
-| Wardline | Built; in active commit-cadence use |
-| Shuttle | Proposed; not in flight; separate design effort when prioritised |
-
-The v0.1 Loom suite is Clarion + Filigree + Wardline. Shuttle enters the suite only when it passes the go/no-go test (§7) and has its own spec, design, and validating customer.
-
-This charter is expected to outlive v0.1 and shape all subsequent product gates. Its load-bearing sentence is in §5: **enrichment, not load-bearing**. If that principle is ever compromised, the rest collapses.
+## What moved, and what changed
+
+The body of this file (the suite doctrine: bounded authority, federation axiom,
+composition law, the enrichment failure-test, the asterisk discipline, the
+go/no-go test, and naming) is now authoritative at
+[`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md). Read it there.
+
+One substantive update the hub makes over the old body of this file: the
+**federation roster is now 5 realized members** — Clarion, Filigree, Wardline,
+**Legis**, and **Charter** — plus **Shuttle as a roadmap thought-bubble** (not a
+committed member). The old §1/§9 three-member framing (Clarion + Filigree +
+Wardline, with Shuttle "proposed") is **superseded** by the hub: Legis and
+Charter shipped/were-designed after this file was last written, and the hub is
+the body that declares the roster. See the doctrine's §1 roster note and
+`~/loom/conflict-register.md` §B-1 for the ruling.
+
+## Companion hub docs
+
+- [`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md) — the authoritative federation doctrine (this file's promoted body)
+- [`~/loom/glossary.md`](file:///home/john/loom/glossary.md) — the cross-product vocabulary catalogue
+- [`~/loom/sei-standard.md`](file:///home/john/loom/sei-standard.md) — the Stable Entity Identity conformance standard
+- [`~/loom/federation-map.md`](file:///home/john/loom/federation-map.md) — the integration matrix
+- [`~/loom/contracts-index.md`](file:///home/john/loom/contracts-index.md) — the cross-product contract index
+- [`~/loom/asterisk-register.md`](file:///home/john/loom/asterisk-register.md) — the live/retired federation asterisks
+
+## Clarion-local notes
+
+The only Clarion-specific detail in the old body was the operator-trust pointer
+for Clarion's HTTP read API (its unauthenticated, loopback-only default and the
+reverse-proxy requirement before any non-loopback bind). That remains documented
+in Clarion's own
+[`docs/operator/clarion-http-read-api.md`](../operator/clarion-http-read-api.md);
+the federation-pattern framing around it lives in the hub doctrine.

From 99066ecaf71c17f5d2e42d2a334092c89403b6a6 Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 06:20:33 +1000
Subject: [PATCH 16/21] fix(cli): drop unnecessary to_owned on db_path in serve
 LLM writer spawn

Writer::spawn takes impl AsRef and db_path is already &Path, so
the to_owned() allocates a throwaway PathBuf. clippy 1.95+ flags this
under unnecessary_to_owned, failing the -D warnings gate (ADR-023 floor)
and blocking PR #39's CI on release/1.3.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 crates/clarion-cli/src/serve.rs | 8 ++------
 1 file changed, 2 insertions(+), 6 deletions(-)

diff --git a/crates/clarion-cli/src/serve.rs b/crates/clarion-cli/src/serve.rs
index af4b5a62..95b0e360 100644
--- a/crates/clarion-cli/src/serve.rs
+++ b/crates/clarion-cli/src/serve.rs
@@ -191,12 +191,8 @@ fn run_mcp_stdio(
     let mut llm_writer = None;
     let mut llm_writer_join = None;
     if let Some(provider) = llm_provider {
-        let (writer, handle) = Writer::spawn(
-            db_path.to_owned(),
-            DEFAULT_BATCH_SIZE,
-            DEFAULT_CHANNEL_CAPACITY,
-        )
-        .map_err(|err| anyhow!("spawn MCP LLM writer for {}: {err}", db_path.display()))?;
+        let (writer, handle) = Writer::spawn(db_path, DEFAULT_BATCH_SIZE, DEFAULT_CHANNEL_CAPACITY)
+            .map_err(|err| anyhow!("spawn MCP LLM writer for {}: {err}", db_path.display()))?;
         state = state.with_summary_llm(writer.sender(), llm_config, provider);
         llm_writer = Some(writer);
         llm_writer_join = Some(handle);

From 0f93b16508c786714dc68513809602e611f51b31 Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 06:32:50 +1000
Subject: [PATCH 17/21] =?UTF-8?q?docs:=20pre-1.3.0=20hygiene=20=E2=80=94?=
 =?UTF-8?q?=20fix=20dead=20links,=20record=20removals,=20repoint=20asteris?=
 =?UTF-8?q?k-2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- README: repoint deleted CLAUDE.md link to the tracked v1.0 docset README
  (CLAUDE.md/AGENTS.md are now intentionally untracked + gitignored).
- docs/suite/*, docs/federation/contracts.md: strip machine-local
  file:///home/john/loom/* hrefs to plain ~/loom/* code spans (the loom hub
  is an external companion repo, not vendored — no valid in-repo target).
- CHANGELOG [1.3.0]: add ### Removed section (governance script handed to
  Legis; CLAUDE.md/AGENTS.md; bundled filigree-workflow SKILL.md); repoint
  the asterisk-2 retirement reference from the now-stubbed loom.md §5 to the
  hub asterisk register.

ADR-018 (Accepted, immutable) and historical CHANGELOG entries are left
unchanged by design.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 CHANGELOG.md                 | 15 +++++++++++++--
 README.md                    |  7 ++++---
 docs/federation/contracts.md |  4 ++--
 docs/suite/README.md         |  2 +-
 docs/suite/briefing.md       |  2 +-
 docs/suite/glossary.md       |  6 +++---
 docs/suite/loom.md           | 16 ++++++++--------
 7 files changed, 32 insertions(+), 20 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a41381f..e9e216b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,8 +24,8 @@ only when an incompatible change is made to that surface. See
   receive `wardline` entity metadata and `wardline:*` tags when the descriptor is
   available; a missing, invalid, or version-skewed descriptor degrades honestly to
   normal structural extraction. This **fully retires** the Clarion-side
-  `wardline.core.registry` startup coupling in [`docs/suite/loom.md`](docs/suite/loom.md)
-  §5 (asterisk 2): plugin startup performs zero in-process Wardline import, so the
+  `wardline.core.registry` startup coupling (federation asterisk 2, registered at
+  `~/loom/asterisk-register.md`): plugin startup performs zero in-process Wardline import, so the
   plugin no longer requires a co-installed Wardline and is robust to Wardline's
   upcoming native core. Plugin-only change (no Rust-core / protocol / ontology
   change); tracked at `clarion-881e9834bc`.
@@ -43,6 +43,17 @@ only when an incompatible change is made to that surface. See
 - Archived tracked architecture-analysis working notes out of live `temp/`
   directories under `docs/archive/working-notes/`.
 
+### Removed
+
+- **Release-governance gate script (`scripts/check-github-release-governance.py`).**
+  Release-governance enforcement is handed off to Legis; `release.yml` no longer
+  invokes the script and the standalone check is removed from the tree.
+- **In-repo agent-instruction files (`CLAUDE.md`, `AGENTS.md`).** Agent
+  conventions now derive from the `~/loom` federation hub plus local untracked
+  copies; both files are removed from the tracked tree and gitignored.
+- **Bundled Filigree-workflow skill (`.agents/skills/filigree-workflow/SKILL.md`).**
+  Removed from the tracked tree alongside the agent-instruction files.
+
 ### Fixed
 
 - **`clarion doctor` reports enrich-only integration bindings as a warning, not a
diff --git a/README.md b/README.md
index d272b17d..0648e3ab 100644
--- a/README.md
+++ b/README.md
@@ -126,9 +126,10 @@ documented in
 
 ## Contributing
 
-Read [CLAUDE.md](CLAUDE.md) for repository conventions, work-package
-vocabulary, and where canonical truth lives. The CI floor every PR must clear
-is fixed by [ADR-023](docs/clarion/adr/ADR-023-tooling-baseline.md):
+Read the [v1.0 docset README](docs/clarion/1.0/README.md) for the canonical
+design ladder, its reading order, and where canonical truth lives. The CI
+floor every PR must clear is fixed by
+[ADR-023](docs/clarion/adr/ADR-023-tooling-baseline.md):
 
 ```bash
 # Rust gates
diff --git a/docs/federation/contracts.md b/docs/federation/contracts.md
index fa11a609..df1ff2db 100644
--- a/docs/federation/contracts.md
+++ b/docs/federation/contracts.md
@@ -15,12 +15,12 @@ Filigree is absent (loom.md §5).
 
 > **Federation-pattern sources (pointers).** The federation axiom and composition
 > law cited as `loom.md §5` throughout this document are now authoritative at the
-> federation hub: [`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md) §5
+> federation hub: `~/loom/doctrine.md` §5
 > (as of 2026-06-05; `loom.md` is now a pointer stub that preserves the original
 > section numbers, so `loom.md §5` resolves there). The cross-product **contract
 > index** — the suite-level list of every live cross-product contract and its
 > owning authority — lives at
-> [`~/loom/contracts-index.md`](file:///home/john/loom/contracts-index.md). The
+> `~/loom/contracts-index.md`. The
 > endpoint specs below are **Clarion-owned and authoritative**; the hub indexes
 > them, it does not restate them.
 
diff --git a/docs/suite/README.md b/docs/suite/README.md
index 007ac2ae..1a638446 100644
--- a/docs/suite/README.md
+++ b/docs/suite/README.md
@@ -2,7 +2,7 @@
 
 This folder holds the Loom-wide documents.
 
-> **Authoritative federation hub:** [`~/loom`](file:///home/john/loom) is the single authoritative source for federation-wide interoperability — the doctrine ([`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md)), the cross-product glossary ([`~/loom/glossary.md`](file:///home/john/loom/glossary.md)), the SEI standard, the federation map, and the contract index. The `loom.md` and `glossary.md` files in this folder are now pointer stubs to the hub (promoted 2026-06-05). Clarion-owned docs (ADRs, federation contracts) remain authoritative in this repo.
+> **Authoritative federation hub:** `~/loom` is the single authoritative source for federation-wide interoperability — the doctrine (`~/loom/doctrine.md`), the cross-product glossary (`~/loom/glossary.md`), the SEI standard, the federation map, and the contract index. The `loom.md` and `glossary.md` files in this folder are now pointer stubs to the hub (promoted 2026-06-05). Clarion-owned docs (ADRs, federation contracts) remain authoritative in this repo.
 
 ## Canonical docs
 
diff --git a/docs/suite/briefing.md b/docs/suite/briefing.md
index 41a843c4..18980f82 100644
--- a/docs/suite/briefing.md
+++ b/docs/suite/briefing.md
@@ -10,7 +10,7 @@
 
 **Loom** is a suite for enterprise-grade code governance on small teams. Its v0.1 products — **Clarion**, **Filigree**, and **Wardline** — are three independent tools that enrich one another through narrow additive protocols. Each is fully authoritative in its domain and fully usable on its own. Clarion builds a trustworthy catalog of a codebase and answers structural questions. Filigree tracks the issues, findings, and observations that arise from examining that codebase. Wardline declares and enforces the trust topology that constrains how code is allowed to behave. Together they deliver rigor that normally requires enterprise-scale platform teams — without the operational weight, and without any shared runtime, store, or orchestrator. A fourth product, **Shuttle**, is proposed for transactional scoped change execution; see [loom.md](./loom.md) for the suite's founding doctrine, the enrichment-not-load-bearing principle, and the go/no-go test that governs future products.
 
-> **Authoritative source.** The Loom federation axiom, roster, and composition law are now authoritative at the federation hub: [`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md) (as of 2026-06-05). The hub's canonical roster is **5 realized members** — Clarion, Filigree, Wardline, **Legis**, and **Charter** — plus **Shuttle as a roadmap thought-bubble**; the three-member v0.1 framing in this briefing predates Legis and Charter and is kept as Clarion's local intro. This briefing remains Clarion's own introduction to the suite as Clarion sees it.
+> **Authoritative source.** The Loom federation axiom, roster, and composition law are now authoritative at the federation hub: `~/loom/doctrine.md` (as of 2026-06-05). The hub's canonical roster is **5 realized members** — Clarion, Filigree, Wardline, **Legis**, and **Charter** — plus **Shuttle as a roadmap thought-bubble**; the three-member v0.1 framing in this briefing predates Legis and Charter and is kept as Clarion's local intro. This briefing remains Clarion's own introduction to the suite as Clarion sees it.
 
 ---
 
diff --git a/docs/suite/glossary.md b/docs/suite/glossary.md
index 4e204faf..d10507ea 100644
--- a/docs/suite/glossary.md
+++ b/docs/suite/glossary.md
@@ -4,7 +4,7 @@
 > The canonical, authoritative cross-product vocabulary catalogue — every term
 > whose meaning crosses product boundaries, with its managed/renamed/no-clash
 > verdict and authority — now lives at
-> **[`~/loom/glossary.md`](file:///home/john/loom/glossary.md)** (authoritative as of 2026-06-05).
+> **`~/loom/glossary.md`** (authoritative as of 2026-06-05).
 > This file is retained as a **pointer stub** so existing references resolve.
 
 ---
@@ -13,11 +13,11 @@ The body of this file (the how-to-use guidance, the ADR-acceptance rule, the
 status legend, and the cross-product term tables — managed clashes, renamed
 clashes, no-clash informational entries, the SP9 Wardline taint-store wire terms,
 deferred clashes, Wardline-side terms, and the Shuttle note) is now authoritative
-at [`~/loom/glossary.md`](file:///home/john/loom/glossary.md). Read and update it
+at `~/loom/glossary.md`. Read and update it
 there.
 
 The federation axiom this glossary defends is the cross-product field-name rule
-in the hub doctrine: [`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md) §8.
+in the hub doctrine: `~/loom/doctrine.md` §8.
 
 Clarion's ADRs (e.g. ADR-004, ADR-017, ADR-022, ADR-024, ADR-036, ADR-038)
 remain Clarion-owned and authoritative for Clarion's own field shapes; the hub
diff --git a/docs/suite/loom.md b/docs/suite/loom.md
index f24ae048..3cc600db 100644
--- a/docs/suite/loom.md
+++ b/docs/suite/loom.md
@@ -3,7 +3,7 @@
 > **This founding doctrine has been promoted to the Loom federation hub.**
 > The canonical, authoritative Loom federation doctrine — federation axiom,
 > roster, composition law, enrichment-not-load-bearing test, and the go/no-go
-> gate for new products — now lives at **[`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md)** (authoritative as of 2026-06-05).
+> gate for new products — now lives at **`~/loom/doctrine.md`** (authoritative as of 2026-06-05).
 > This file is retained as a **pointer stub** so existing references resolve.
 >
 > The doctrine was promoted faithfully and **preserves the original section
@@ -18,7 +18,7 @@
 The body of this file (the suite doctrine: bounded authority, federation axiom,
 composition law, the enrichment failure-test, the asterisk discipline, the
 go/no-go test, and naming) is now authoritative at
-[`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md). Read it there.
+`~/loom/doctrine.md`. Read it there.
 
 One substantive update the hub makes over the old body of this file: the
 **federation roster is now 5 realized members** — Clarion, Filigree, Wardline,
@@ -31,12 +31,12 @@ the body that declares the roster. See the doctrine's §1 roster note and
 
 ## Companion hub docs
 
-- [`~/loom/doctrine.md`](file:///home/john/loom/doctrine.md) — the authoritative federation doctrine (this file's promoted body)
-- [`~/loom/glossary.md`](file:///home/john/loom/glossary.md) — the cross-product vocabulary catalogue
-- [`~/loom/sei-standard.md`](file:///home/john/loom/sei-standard.md) — the Stable Entity Identity conformance standard
-- [`~/loom/federation-map.md`](file:///home/john/loom/federation-map.md) — the integration matrix
-- [`~/loom/contracts-index.md`](file:///home/john/loom/contracts-index.md) — the cross-product contract index
-- [`~/loom/asterisk-register.md`](file:///home/john/loom/asterisk-register.md) — the live/retired federation asterisks
+- `~/loom/doctrine.md` — the authoritative federation doctrine (this file's promoted body)
+- `~/loom/glossary.md` — the cross-product vocabulary catalogue
+- `~/loom/sei-standard.md` — the Stable Entity Identity conformance standard
+- `~/loom/federation-map.md` — the integration matrix
+- `~/loom/contracts-index.md` — the cross-product contract index
+- `~/loom/asterisk-register.md` — the live/retired federation asterisks
 
 ## Clarion-local notes
 

From 5bdbced231cecc40f86f7279462187f208638b4b Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 06:59:36 +1000
Subject: [PATCH 18/21] docs/chore: track Wardline Task B confirmation; flag
 removed governance script

- wardline_descriptor.py: reference filigree clarion-6ab5668d82 from the
  PO-confirm marker + module docstring, so the deferred Wardline descriptor
  contract confirmation (PROJECT_DESCRIPTOR_PATH + version semantics) is
  tracked rather than a bare inline note.
- v1.0-cicd-readiness.md, v1.0-tag-cut/execution-plan.md: add dated superseded
  banners noting scripts/check-github-release-governance.py was removed
  post-v1.0 (governance handed to Legis) so nobody follows a dead step. Bodies
  left intact for v1.0 provenance; archive snapshots + the point-in-time audit
  doc untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 docs/implementation/v1.0-cicd-readiness.md                  | 6 ++++++
 docs/implementation/v1.0-tag-cut/execution-plan.md          | 6 ++++++
 .../python/src/clarion_plugin_python/wardline_descriptor.py | 4 +++-
 3 files changed, 15 insertions(+), 1 deletion(-)

diff --git a/docs/implementation/v1.0-cicd-readiness.md b/docs/implementation/v1.0-cicd-readiness.md
index 48b53de0..f5d923b7 100644
--- a/docs/implementation/v1.0-cicd-readiness.md
+++ b/docs/implementation/v1.0-cicd-readiness.md
@@ -5,6 +5,12 @@
 **Decision basis**: ADR-023 tooling baseline, ADR-033 distribution path, and the
 v1.0 operator install surface.
 
+> **Superseded note (2026-06-05, post-1.3.0).** This is a historical v1.0
+> readiness record. `scripts/check-github-release-governance.py`, referenced
+> below, was **removed** after v1.0; release-governance enforcement is handed
+> off to Legis and `release.yml` no longer invokes it. Do not run the script —
+> it no longer exists. The record is retained as-is for v1.0 provenance.
+
 ## Pipeline Map
 
 Clarion has two release-relevant GitHub Actions workflows:
diff --git a/docs/implementation/v1.0-tag-cut/execution-plan.md b/docs/implementation/v1.0-tag-cut/execution-plan.md
index 9ab66b4b..e4127658 100644
--- a/docs/implementation/v1.0-tag-cut/execution-plan.md
+++ b/docs/implementation/v1.0-tag-cut/execution-plan.md
@@ -4,6 +4,12 @@
 **Closes**: every gap in [`gap-register.md`](gap-register.md).
 **Total effort**: ~13 hours engineering + ~3.5 hours operator.
 
+> **Superseded note (2026-06-05, post-1.3.0).** Historical v1.0.0 tag-cut
+> record. `scripts/check-github-release-governance.py` (and the `gap-register.md`
+> gaps that reference it) was **removed** after v1.0; release-governance
+> enforcement is handed off to Legis and `release.yml` no longer invokes it. The
+> steps below that run that script are dead — retained as-is for v1.0 provenance.
+
 This plan sequences the gaps into three days of focused work, with explicit
 parallel-execution markers. The first day is mechanical doc + bug fixes
 that have no inter-dependencies and can run as parallel PRs. Day 2 is the
diff --git a/plugins/python/src/clarion_plugin_python/wardline_descriptor.py b/plugins/python/src/clarion_plugin_python/wardline_descriptor.py
index 2483e0f8..c4d4a07d 100644
--- a/plugins/python/src/clarion_plugin_python/wardline_descriptor.py
+++ b/plugins/python/src/clarion_plugin_python/wardline_descriptor.py
@@ -11,7 +11,8 @@
 format-version field. The parser ignores unknown top-level keys, so a future
 ``schema`` field is tolerated without change; acting on it (format-version
 compatibility decisions) is deferred until Task B pins the contract. Confirm
-both assumptions against the Wardline descriptor ADR when it lands.
+both assumptions against the Wardline descriptor ADR when it lands
+(tracked: filigree clarion-6ab5668d82).
 """
 
 from __future__ import annotations
@@ -25,6 +26,7 @@
 
 # PO-confirm against Wardline Task B (descriptor ADR) — canonical project-local
 # location and descriptor-version semantics are not yet pinned by Wardline.
+# Tracked: filigree clarion-6ab5668d82.
 EXPECTED_DESCRIPTOR_VERSION = "wardline-generic-2"
 PROJECT_DESCRIPTOR_PATH = Path(".wardline/vocabulary.yaml")
 

From 2b8a31b88d38ff857c08dd1a5e414c1473bdd11f Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 07:27:13 +1000
Subject: [PATCH 19/21] fix(security): stop untrusted-repo git
 config/attributes executing helpers (clarion-4b5a8aff54)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

`clarion analyze` (SEI git-rename signal) and `clarion serve` (index_diff_get
freshness report) shelled `git` inside the analyzed repository with repo-local
config and Git attributes enabled. A malicious repository could execute
arbitrary commands as the local user during an ordinary analyze/serve via
`core.fsmonitor`, an external diff/textconv driver, or a `filter..clean`
selected by a `filter` attribute. This breaches the untrusted-corpus posture
behind the plugin jail (ADR-021) and the pre-ingest secret scanner.

Two layers, because no single config flag closes every vector:

1. A shared helper `clarion_core::hardened_git_command` routes every
   corpus-facing git call and neutralizes the config + attribute sources it can:
   ignores operator/global/system config (GIT_CONFIG_NOSYSTEM, GIT_CONFIG_GLOBAL/
   SYSTEM -> null) and system attributes (GIT_ATTR_NOSYSTEM); strips
   config/exec-injecting env (GIT_CONFIG_COUNT, GIT_EXTERNAL_DIFF, GIT_DIFF_OPTS,
   GIT_ATTR_SOURCE, GIT_PAGER); overrides the program-naming repo-local keys via
   highest-precedence -c (core.fsmonitor=false, diff.external=, core.pager=cat,
   core.attributesFile=); and reads in-tree attributes from the empty tree
   (--attr-source) when the local git supports it.

2. `--attr-source` does NOT cover `$GIT_DIR/info/attributes`, and no config flag
   does — that source only triggers a filter when git hashes working-tree
   content. So the call sites no longer hash the worktree on an untrusted corpus:
   the rename diff uses `git diff --cached` (index vs HEAD; still catches staged
   `git mv` renames), and gather_git_facts replaces `git status` with
   `git diff --cached` plus the existing stat-based per-file drift check. `--cached`
   (not `--attr-source`) is the actual control, so `--attr-source` is gated on a
   one-time `git --version >= 2.40` probe: older git omits it and stays both safe
   and functional (no minimum-git floor introduced).

Behavior change: index_diff `dirty_files` now lists staged changes only;
unstaged working-tree modifications and untracked files are not enumerated there
(unstaged edits to indexed files still surface in `modified_since_analyze`).

Regression tests arm ALL exec sources at once — core.fsmonitor + filter.*.clean
via in-tree .gitattributes, $GIT_DIR/info/attributes, and core.attributesFile —
for both the rename `diff --cached` path and the index_diff facts path, asserting
no helper executes and that staged rename / drift detection still works. Each
vector was confirmed firing against the prior (config-only) approach before the
--cached change. Full gate green (fmt, clippy -D warnings, build, 1145 nextest,
doc -D warnings, deny, e2e walking skeleton).

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 CHANGELOG.md                            |  32 ++++
 crates/clarion-cli/src/sei_git.rs       | 227 ++++++++++++++++++-----
 crates/clarion-core/src/hardened_git.rs | 232 ++++++++++++++++++++++++
 crates/clarion-core/src/lib.rs          |   2 +
 crates/clarion-mcp/src/index_diff.rs    | 191 +++++++++++++++----
 5 files changed, 602 insertions(+), 82 deletions(-)
 create mode 100644 crates/clarion-core/src/hardened_git.rs

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9e216b1..e6de20c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -65,6 +65,38 @@ only when an incompatible change is made to that surface. See
   `problem`. A bare `clarion doctor` on a no-bindings (Clarion-solo or
   Clarion+Filigree-only) project now exits 0 with the warning surfaced.
 
+### Security
+
+- **Closed a config-driven command-execution path from untrusted repository
+  contents (`clarion-4b5a8aff54`).** `clarion analyze` (the SEI git-rename
+  signal) and `clarion serve` (the `index_diff_get` freshness/drift report)
+  shelled `git` inside the analyzed repository with repo-local configuration and
+  Git attributes enabled, so a malicious repository could execute arbitrary
+  commands as the local user during an ordinary analyze/serve — via
+  `core.fsmonitor`, an external diff/textconv driver, or a `filter..clean`
+  selected by a `filter` attribute. All corpus-facing `git` calls now route
+  through a single hardened helper (`clarion_core::hardened_git_command`) that
+  ignores operator/global/system config, strips config/exec-injecting environment
+  variables, overrides the program-naming repo-local keys via highest-precedence
+  `-c` flags (including `core.fsmonitor=false` and `core.attributesFile=`), and
+  neutralizes the attribute sources it can — the in-tree `.gitattributes` via
+  `--attr-source=` and the system file via `GIT_ATTR_NOSYSTEM`. The
+  one source no config flag can disable, `$GIT_DIR/info/attributes`, only triggers
+  a filter when Git hashes working-tree content, so the call sites no longer do
+  that on an untrusted corpus: the rename signal uses `git diff --cached`
+  (index-vs-HEAD; still catches staged `git mv` renames) and the freshness probe
+  replaces `git status` with `git diff --cached` plus the existing stat-based
+  per-file drift check. **Behavior change:** `index_diff`'s `dirty_files` now
+  lists staged changes only — unstaged working-tree modifications and untracked
+  files are no longer enumerated there (unstaged edits to indexed files still
+  surface in `modified_since_analyze`). Signals remain best-effort and
+  enrich-only. The `--attr-source` belt-and-suspenders is applied only when a
+  one-time probe confirms Git >= 2.40; older Git omits it and stays both safe (the
+  `--cached` call sites carry the actual protection) and fully functional, so no
+  minimum-Git floor is introduced. Reported externally and re-verified with
+  working PoCs across all attribute sources; relates to the untrusted-corpus
+  posture of ADR-021.
+
 ## [1.2.0] — 2026-06-03
 
 ### Added
diff --git a/crates/clarion-cli/src/sei_git.rs b/crates/clarion-cli/src/sei_git.rs
index 82ce22f1..710e87a9 100644
--- a/crates/clarion-cli/src/sei_git.rs
+++ b/crates/clarion-cli/src/sei_git.rs
@@ -5,9 +5,12 @@
 //! implement the same trait, with a shared file→locator translation:
 //!
 //! - [`ShellGitRenameSource`] — the v1 concrete supplier: shells
-//!   `git diff --name-status -M` for *file* renames and translates each into the
-//!   locator renames it implies, by substituting the renamed file's module prefix
-//!   across the current run's locator set.
+//!   `git diff --cached --name-status -M` for *file* renames and translates each
+//!   into the locator renames it implies, by substituting the renamed file's
+//!   module prefix across the current run's locator set. The `--cached` (index)
+//!   window — rather than a working-tree diff — is what keeps the probe from
+//!   executing repo-controlled filters on an untrusted corpus (clarion-4b5a8aff54);
+//!   see [`hardened_git_command`] and `ShellGitRenameSource::run_git_diff`.
 //! - [`LegisGitRenameSource`] — the WS9 supplier: reads `legis`'s
 //!   `GET /git/renames?rev_range=…` over HTTP and feeds the *same* translation,
 //!   so `legis` becomes the first external supplier with no change to the matcher
@@ -15,13 +18,16 @@
 //!
 //! ## Window mismatch (the WS9 load-bearing fact)
 //! The two suppliers observe **different rename windows**:
-//! - `ShellGitRenameSource` diffs the **working tree against `HEAD`** (`git diff -M
-//!   HEAD`, empty base) → *uncommitted* renames: the "rename a file, then
-//!   re-analyze before commit" flow `analyze` depends on today.
+//! - `ShellGitRenameSource` diffs the **staged index against `HEAD`** (`git diff
+//!   --cached -M HEAD`, empty base) → *staged-but-uncommitted* renames: the
+//!   "`git mv` a file, then re-analyze before commit" flow `analyze` depends on
+//!   today. (`--cached` rather than a worktree diff is the untrusted-corpus
+//!   hardening, clarion-4b5a8aff54; a rename only becomes a `-M` rename once
+//!   staged, so the signal is preserved.)
 //! - `legis`'s endpoint serves only **committed** renames over a rev-range
-//!   (`git log -M`). In the working-tree flow it returns empty.
+//!   (`git log -M`). In the staged-index flow it returns empty.
 //!
-//! So [`select_git_rename_source`] is **capability-aware**: for the working-tree
+//! So [`select_git_rename_source`] is **capability-aware**: for the staged-index
 //! window (empty base) the shell source is the authority regardless of `legis`;
 //! `legis` is selected only for a committed rev-range when configured AND
 //! reachable. This guarantees Clarion-with-`legis` is never *worse* than
@@ -30,13 +36,13 @@
 //! hash — so neither window choice can cause a *false* carry, only a missed one.
 //!
 //! Both windows are now driven each run by [`gather_git_renames`], which unions
-//! the working-tree window (always, shell) with the committed window
+//! the staged-index window (always, shell) with the committed window
 //! `..HEAD` (`legis`-gated). `analyze` records the HEAD it analyzed
 //! on the run row (`runs.analyzed_at_commit`) and reads the prior run's commit to
 //! drive the committed window — so a `legis` configured against a repo with
 //! commits between runs is *operatively* consulted, closing the WS9 window gap
 //! formerly disclosed in `docs/federation/contracts.md`. Without `legis`, or with
-//! no prior commit, only the working-tree window runs (byte-identical to pre-WS9).
+//! no prior commit, only the staged-index window runs (enrich-only as pre-WS9).
 //!
 //! ## Scope (v1, honest framing)
 //! - Path→module translation is Python-shaped (strip the extension, drop a
@@ -46,9 +52,9 @@
 //!   (best-effort): the move case (identical body + signature) carries the load.
 
 use std::path::{Path, PathBuf};
-use std::process::Command;
 use std::time::Duration;
 
+use clarion_core::hardened_git_command;
 use clarion_storage::{GitRename, GitRenameSource};
 
 /// How long to wait on a `legis` HTTP probe/read before giving up and degrading
@@ -58,7 +64,7 @@ use clarion_storage::{GitRename, GitRenameSource};
 /// bounded, not infinite. On the committed-base path the bound is *sequential* —
 /// [`legis_reachable`]'s `/health` probe then [`LegisGitRenameSource::fetch_renames`]'s
 /// read — so a dead peer degrades to empty after at most `2 × LEGIS_HTTP_TIMEOUT`.
-/// (The default working-tree path never reaches `legis` at all; see
+/// (The default staged-index path never reaches `legis` at all; see
 /// [`select_git_rename_source`].) The connection-refused degrade is covered by
 /// `legis_source_unreachable_degrades_to_empty`; the timeout-firing case is left
 /// to this by-construction bound rather than a deliberately-slow test.
@@ -82,13 +88,26 @@ impl ShellGitRenameSource {
         }
     }
 
-    /// Run `git diff --name-status -M ` in the repo and return its stdout,
-    /// or `None` on any failure (not a repo, git missing, non-zero exit).
+    /// Run `git diff --cached --name-status -M ` in the repo and return its
+    /// stdout, or `None` on any failure (not a repo, git missing, non-zero exit).
     fn run_git_diff(&self, base: &str) -> Option {
-        let output = Command::new("git")
-            .arg("-C")
-            .arg(&self.repo_root)
-            .args(["diff", "--name-status", "-M"])
+        // Hardened against the untrusted corpus (clarion-4b5a8aff54). `--cached`
+        // diffs the *index* against  and never hashes working-tree content,
+        // which is what closes the one filter-exec source the hardened command
+        // cannot (`$GIT_DIR/info/attributes`); see `hardened_git_command`. It also
+        // preserves the signal: `git mv` stages a rename, so a pre-commit
+        // re-analyze still sees it (a plain `mv` without `git add` is not a `-M`
+        // rename in any window). `--no-ext-diff`/`--no-textconv` are
+        // belt-and-suspenders over the helper's config overrides.
+        let output = hardened_git_command(&self.repo_root)
+            .args([
+                "diff",
+                "--cached",
+                "--no-ext-diff",
+                "--no-textconv",
+                "--name-status",
+                "-M",
+            ])
             .arg(base)
             .output()
             .ok()?;
@@ -101,7 +120,8 @@ impl ShellGitRenameSource {
 
 impl GitRenameSource for ShellGitRenameSource {
     fn renames_since(&self, base_commit: &str) -> Vec {
-        // An empty base means "compare the working tree to HEAD".
+        // An empty base means "compare the staged index to HEAD" (the `--cached`
+        // window; see `run_git_diff` for why it is index- not worktree-based).
         let base = if base_commit.is_empty() {
             "HEAD"
         } else {
@@ -220,7 +240,7 @@ impl LegisGitRenameSource {
 
 impl GitRenameSource for LegisGitRenameSource {
     fn renames_since(&self, base_commit: &str) -> Vec {
-        // The working-tree window (empty base) is precisely what `legis`'s
+        // The staged-index window (empty base) is precisely what `legis`'s
         // committed-rev-range endpoint cannot answer. Return empty rather than
         // issue a request that can only come back empty — and so that
         // `select_git_rename_source` callers that reach here for the wrong window
@@ -309,10 +329,10 @@ fn legis_reachable(base_url: &str) -> bool {
 /// Capability-aware, enrich-only selection of the git-rename supplier (REQ-C-05,
 /// loom.md §5). `legis` is chosen ONLY when it is configured, the window is a
 /// committed rev-range (`!base.is_empty()`), AND it is reachable; in every other
-/// case — `legis` absent/unset/unreachable, or the working-tree window the shell
+/// case — `legis` absent/unset/unreachable, or the staged-index window the shell
 /// source alone can answer — the shell source is the authority. The
 /// `base.is_empty()` check short-circuits *before* any network probe, so the
-/// default working-tree path issues no HTTP and is byte-identical to pre-WS9
+/// default staged-index path issues no HTTP and is byte-identical to pre-WS9
 /// behaviour. See this module's "Window mismatch" note.
 pub(crate) fn select_git_rename_source(
     project_root: &Path,
@@ -335,9 +355,7 @@ pub(crate) fn select_git_rename_source(
 /// True if `path` is inside a git work tree (used to skip the git probe
 /// entirely on non-repo corpora, avoiding a spurious subprocess per run).
 pub(crate) fn is_git_repo(path: &Path) -> bool {
-    Command::new("git")
-        .arg("-C")
-        .arg(path)
+    hardened_git_command(path)
         .args(["rev-parse", "--is-inside-work-tree"])
         .output()
         .is_ok_and(|o| o.status.success())
@@ -349,9 +367,7 @@ pub(crate) fn is_git_repo(path: &Path) -> bool {
 /// SEI §6). Fail-soft like [`is_git_repo`]: an absent SHA simply skips the
 /// committed window, never errors the run.
 pub(crate) fn git_head_sha(repo_root: &Path) -> Option {
-    let output = Command::new("git")
-        .arg("-C")
-        .arg(repo_root)
+    let output = hardened_git_command(repo_root)
         .args(["rev-parse", "HEAD"])
         .output()
         .ok()?;
@@ -364,9 +380,10 @@ pub(crate) fn git_head_sha(repo_root: &Path) -> Option {
 
 /// The git-rename windows to query, in order (WS9 / SEI §6). Pure — no I/O.
 ///
-/// Always includes the **working-tree window** (`""`), which the selector routes
-/// to the shell source (`git diff -M HEAD`) and which catches *uncommitted*
-/// renames — the pre-commit re-analyze flow `analyze` depends on. THE WS9 CRUX:
+/// Always includes the **staged-index window** (`""`), which the selector routes
+/// to the shell source (`git diff --cached -M HEAD`) and which catches
+/// *staged-but-uncommitted* renames — the pre-commit re-analyze flow `analyze`
+/// depends on. THE WS9 CRUX:
 /// this window must never be handed to `legis`, whose committed-only endpoint
 /// cannot see it (see [`select_git_rename_source`]).
 ///
@@ -389,15 +406,16 @@ fn rename_windows(legis_set: bool, prior: Option<&str>, head: Option<&str>) -> V
 
 /// Gather locator renames across both windows and union them (WS9 / SEI §6).
 ///
-/// The two windows are complementary: the working tree (uncommitted renames, via
-/// the shell source) and the committed range `prior..HEAD` (committed renames,
-/// via `legis` when reachable, else a shell `git diff -M prior` fallback). The
+/// The two windows are complementary: the staged index (staged-but-uncommitted
+/// renames, via the shell source) and the committed range `prior..HEAD`
+/// (committed renames, via `legis` when reachable, else a shell
+/// `git diff --cached -M prior` fallback). The
 /// matcher is fail-closed — a rename is only a hint, confirmed by a
 /// byte-identical body hash — so an over-broad union can only *miss* a carry,
 /// never cause a false one; dedup is for tidiness, not correctness.
 ///
 /// Returns empty on a non-git corpus (no spurious subprocess). When `legis` is
-/// unset this issues exactly the one pre-WS9 working-tree call.
+/// unset this issues exactly the one pre-WS9 staged-index call.
 pub(crate) fn gather_git_renames(
     project_root: &Path,
     legis_url: Option<&str>,
@@ -428,6 +446,9 @@ pub(crate) fn gather_git_renames(
 #[cfg(test)]
 mod tests {
     use super::*;
+    // Raw `git` is fine in tests: these build a trusted fixture repo, not probe
+    // an untrusted corpus.
+    use std::process::Command;
 
     #[test]
     fn parses_rename_lines_and_ignores_others() {
@@ -594,7 +615,7 @@ mod tests {
 
     #[test]
     fn legis_source_empty_base_returns_empty_without_network() {
-        // The working-tree window: legis cannot serve it. The source must return
+        // The staged-index window: legis cannot serve it. The source must return
         // empty WITHOUT a request — proven by pointing at an unroutable address
         // that would hang/timeout if a request were issued.
         let src = LegisGitRenameSource::new(
@@ -678,7 +699,7 @@ mod tests {
     ///
     /// A working-tree (uncommitted) rename is exactly what `legis`'s committed
     /// endpoint CANNOT see — a reachable `legis` returns `[]` here. If the
-    /// selector handed the working-tree window to `legis`, the shell-detectable
+    /// selector handed the staged-index window to `legis`, the shell-detectable
     /// rename would be LOST and the entity's SEI would orphan instead of carry.
     /// The capability guard (`!base.is_empty()`) keeps the shell source for this
     /// window, so Clarion-with-`legis` is never worse than Clarion-without.
@@ -692,7 +713,7 @@ mod tests {
         std::fs::write(repo.join("auth.py"), "def login():\n    return 1\n").unwrap();
         run_git(repo, &["add", "."]);
         run_git(repo, &["commit", "-qm", "init"]);
-        // Uncommitted rename: visible to `git diff -M HEAD`, invisible to a
+        // Staged rename (`git mv`): visible to `git diff --cached -M HEAD`, invisible to a
         // committed-range query — just as a real pre-commit re-analyze would be.
         run_git(repo, &["mv", "auth.py", "authn.py"]);
 
@@ -701,7 +722,7 @@ mod tests {
         let src = select_git_rename_source(
             repo,
             Some(format!("http://{addr}")),
-            "", // the operative working-tree window
+            "", // the operative staged-index window
             vec![
                 "python:function:authn.login".to_owned(),
                 "python:module:authn".to_owned(),
@@ -744,7 +765,7 @@ mod tests {
     #[test]
     fn rename_windows_skips_committed_window_on_degenerate_base() {
         // No prior, empty prior, or prior == head (empty `base..HEAD` range) all
-        // collapse to the working-tree window — no wasted committed query/probe.
+        // collapse to the staged-index window — no wasted committed query/probe.
         assert_eq!(
             rename_windows(true, None, Some("head")),
             vec![String::new()]
@@ -763,8 +784,8 @@ mod tests {
     ///
     /// A committed rename (`auth.py→authn.py`, served by the legis mock for the
     /// `..HEAD` window) AND an *uncommitted* rename (`extra.py→extras.py`,
-    /// seen only by the shell working-tree window) must BOTH appear. A swap would
-    /// route the working-tree window to legis and drop the uncommitted one.
+    /// seen only by the shell staged-index window) must BOTH appear. A swap would
+    /// route the staged-index window to legis and drop the uncommitted one.
     #[test]
     fn gather_unions_committed_legis_and_working_tree_shell_renames() {
         let dir = tempfile::tempdir().unwrap();
@@ -782,7 +803,7 @@ mod tests {
         run_git(repo, &["commit", "-qm", "rename auth"]);
         let head = git_head_sha(repo).expect("head sha");
         // Uncommitted rename — invisible to the committed window, only the shell
-        // working-tree window can see it.
+        // staged-index window can see it.
         run_git(repo, &["mv", "extra.py", "extras.py"]);
 
         // legis mock answers the committed window with the committed rename.
@@ -821,7 +842,7 @@ mod tests {
 
     #[test]
     fn gather_without_legis_issues_only_working_tree_window() {
-        // No legis_url: only the working-tree window runs (the committed rename is
+        // No legis_url: only the staged-index window runs (the committed rename is
         // NOT picked up, proving the committed window is legis-gated and the
         // no-legis path is byte-identical to pre-WS9).
         let dir = tempfile::tempdir().unwrap();
@@ -846,7 +867,7 @@ mod tests {
         );
         assert!(
             got.is_empty(),
-            "committed rename must be invisible without legis (working-tree window only); got {got:?}"
+            "committed rename must be invisible without legis (staged-index window only); got {got:?}"
         );
     }
 
@@ -857,4 +878,120 @@ mod tests {
         let dir = tempfile::tempdir_in(tmp).unwrap();
         assert!(git_head_sha(dir.path()).is_none());
     }
+
+    /// Build a repo with a renamed-and-modified tracked file plus an
+    /// attacker-controlled, repo-local Git feature that points at an executable
+    /// dropper in the worktree. Returns `(tempdir, marker_path)`. The marker is
+    /// written by the dropper iff Git executes it. `set_repo_local` installs the
+    /// hostile config (and `.gitattributes` for the filter vector).
+    #[cfg(unix)]
+    fn hostile_rename_repo(
+        set_repo_local: impl FnOnce(&Path, &Path),
+    ) -> (tempfile::TempDir, std::path::PathBuf) {
+        use std::os::unix::fs::PermissionsExt;
+
+        let dir = tempfile::tempdir().unwrap();
+        let repo = dir.path();
+        run_git(repo, &["init", "-q"]);
+        run_git(repo, &["config", "user.email", "t@t"]);
+        run_git(repo, &["config", "user.name", "t"]);
+        std::fs::write(repo.join("auth.py"), "def login():\n    return 1\n").unwrap();
+        run_git(repo, &["add", "."]);
+        run_git(repo, &["commit", "-qm", "init"]);
+        // `git mv` stages a rename AND mutates the file's stat, forcing Git to
+        // re-hash working-tree content on the next diff — the condition a clean
+        // filter needs to fire. This is the working-tree rename window itself.
+        run_git(repo, &["mv", "auth.py", "authn.py"]);
+        std::fs::write(repo.join("authn.py"), "def login():\n    return 2\n").unwrap();
+
+        let marker = repo.join("PAYLOAD_FIRED");
+        let payload = repo.join("payload.sh");
+        // `cat` so the script is a valid clean filter (passes content through).
+        std::fs::write(
+            &payload,
+            format!("#!/bin/sh\necho fired > {}\ncat\n", marker.display()),
+        )
+        .unwrap();
+        let mut perms = std::fs::metadata(&payload).unwrap().permissions();
+        perms.set_mode(0o755);
+        std::fs::set_permissions(&payload, perms).unwrap();
+
+        set_repo_local(repo, &payload);
+        (dir, marker)
+    }
+
+    /// REGRESSION (clarion-4b5a8aff54): a repo-local `core.fsmonitor` program
+    /// must not execute when the rename source shells `git diff` against an
+    /// untrusted corpus, and the rename must still be detected.
+    #[cfg(unix)]
+    #[test]
+    fn shell_rename_source_does_not_execute_repo_fsmonitor() {
+        let (dir, marker) = hostile_rename_repo(|repo, payload| {
+            run_git(
+                repo,
+                &["config", "core.fsmonitor", &payload.display().to_string()],
+            );
+        });
+        let got = ShellGitRenameSource::new(
+            dir.path().to_path_buf(),
+            vec!["python:module:authn".to_owned()],
+        )
+        .renames_since("");
+        assert!(
+            !marker.exists(),
+            "repo-local core.fsmonitor executed during the git rename probe"
+        );
+        assert!(
+            got.contains(&GitRename {
+                old_locator: "python:module:auth".to_owned(),
+                new_locator: "python:module:authn".to_owned(),
+            }),
+            "hardened git diff must still detect the rename; got {got:?}"
+        );
+    }
+
+    /// REGRESSION (clarion-4b5a8aff54): a repo-local `filter..clean` must
+    /// not execute regardless of WHICH attribute source selects it. This arms all
+    /// four sources at once — in-tree `.gitattributes`, `$GIT_DIR/info/attributes`
+    /// (which NO config flag can disable), and `core.attributesFile` — plus
+    /// `core.fsmonitor`. The diff is safe because it never hashes working-tree
+    /// content (`--cached`), so no source can select an executing filter.
+    #[cfg(unix)]
+    #[test]
+    fn shell_rename_source_does_not_execute_repo_clean_filter_from_any_attr_source() {
+        let (dir, marker) = hostile_rename_repo(|repo, payload| {
+            let cmd = payload.display().to_string();
+            // Three independent attribute sources, each assigning `filter=evil`.
+            std::fs::write(repo.join(".gitattributes"), "* filter=evil\n").unwrap();
+            std::fs::write(repo.join(".git/info/attributes"), "* filter=evil\n").unwrap();
+            std::fs::write(repo.join("extra-attrs"), "* filter=evil\n").unwrap();
+            run_git(
+                repo,
+                &[
+                    "config",
+                    "core.attributesFile",
+                    &repo.join("extra-attrs").display().to_string(),
+                ],
+            );
+            run_git(repo, &["config", "filter.evil.clean", &cmd]);
+            // And the fsmonitor program, for good measure.
+            run_git(repo, &["config", "core.fsmonitor", &cmd]);
+        });
+        let got = ShellGitRenameSource::new(
+            dir.path().to_path_buf(),
+            vec!["python:module:authn".to_owned()],
+        )
+        .renames_since("");
+        assert!(
+            !marker.exists(),
+            "a repo-controlled filter/fsmonitor executed during the git rename probe"
+        );
+        assert!(
+            got.contains(&GitRename {
+                old_locator: "python:module:auth".to_owned(),
+                new_locator: "python:module:authn".to_owned(),
+            }),
+            "hardened git diff must still detect the staged rename; got {got:?}"
+        );
+    }
 }
diff --git a/crates/clarion-core/src/hardened_git.rs b/crates/clarion-core/src/hardened_git.rs
new file mode 100644
index 00000000..91ef0019
--- /dev/null
+++ b/crates/clarion-core/src/hardened_git.rs
@@ -0,0 +1,232 @@
+//! Hardened `git` invocation for read-only probes against an **untrusted**
+//! corpus.
+//!
+//! Clarion analyzes and serves repositories whose contents are not trusted (the
+//! same posture that motivates the plugin jail, ADR-021, and the pre-ingest
+//! secret scanner). Running `git` inside such a repo is a command-execution
+//! hazard: repo-local configuration and Git *attributes* can name programs that
+//! Git executes during ordinary *read* commands. The known config/attribute
+//! vectors that turn a read into code execution are:
+//!
+//! - `core.fsmonitor=` — run on index refresh (fires on a fresh clone);
+//! - `diff.external` / `GIT_EXTERNAL_DIFF`, `diff..textconv` — content diff;
+//! - `core.pager` — paged output;
+//! - `filter..clean` / `.smudge` / `.process`, **selected by a `filter`
+//!   attribute** — run whenever Git hashes working-tree content (status, a
+//!   worktree diff, rename-similarity scoring).
+//!
+//! [`hardened_git_command`] is the ONLY sanctioned way to spawn `git` against a
+//! corpus path. It neutralizes the config vectors and every attribute source it
+//! *can* reach, at the config/argument level (no sandboxing, no new dependency,
+//! no change to the read *output*):
+//!
+//! - operator/global/system config is ignored (`GIT_CONFIG_NOSYSTEM`,
+//!   `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` → null device), and env-borne config/
+//!   exec injection is stripped (`GIT_CONFIG_COUNT`, `GIT_EXTERNAL_DIFF`,
+//!   `GIT_DIFF_OPTS`, `GIT_ATTR_SOURCE`, `GIT_PAGER`);
+//! - the remaining (still-untrusted) repo-local config is overridden where it can
+//!   name a program, via highest-precedence `-c` flags (`core.fsmonitor=false`,
+//!   `diff.external=`, `core.pager=cat`, `core.untrackedCache=false`,
+//!   `core.attributesFile=` → null device);
+//! - the **attribute sources** that select a `filter`/diff/textconv driver are
+//!   neutralized: the per-directory in-tree `.gitattributes` via `--attr-source`
+//!   (read from the empty tree → no path gets an attribute), the system
+//!   attributes file via `GIT_ATTR_NOSYSTEM`, and `core.attributesFile` via the
+//!   `-c` override above.
+//!
+//! ## The one source config cannot reach: `$GIT_DIR/info/attributes`
+//! Git always consults `$GIT_DIR/info/attributes`, and **no config key or
+//! environment variable disables it** (`--attr-source` only redirects the
+//! *worktree* `.gitattributes`; `GIT_ATTR_NOSYSTEM` only affects the *system*
+//! file). An attacker who ships a crafted `.git` directory can therefore still
+//! place `* filter=evil` there. The filter only *executes* when Git hashes
+//! working-tree content, so the residual is closed not in this helper but at the
+//! **call site**, by never hashing the working tree on an untrusted corpus:
+//!
+//! - the SEI rename diff uses `git diff --cached` (index vs HEAD — no worktree
+//!   hash; still sees staged `git mv` renames);
+//! - the index-freshness probe avoids `git status` (which must hash the worktree)
+//!   in favour of `git diff --cached` plus the stat-based per-file drift check.
+//!
+//! Read commands that never hash working-tree content (`rev-parse`, `log`,
+//! `diff --cached`) are safe through this helper regardless of
+//! `info/attributes`. See `clarion-4b5a8aff54`.
+//!
+//! `--attr-source` requires Git >= 2.40, so it is added only when a one-time
+//! `git --version` probe confirms support (see `attr_source_supported`); older
+//! Git omits it and stays safe, because `--cached` — not `--attr-source` — is the
+//! control that closes the vuln. This avoids silently raising the minimum Git or
+//! blanking the (best-effort) signal on Debian/Ubuntu-LTS Git. SHA-256
+//! repositories (whose empty tree OID differs from the SHA-1 constant below) make
+//! the `--attr-source` resolve fail; the read then fails soft to empty (secure),
+//! and the in-tree-attribute belt-and-suspenders is simply inactive — again, the
+//! `--cached` call sites carry the actual safety.
+
+use std::path::Path;
+use std::process::Command;
+use std::sync::OnceLock;
+
+/// The well-known empty tree object (SHA-1). Reading gitattributes from this
+/// tree assigns no attribute to any path, so no `filter`/diff/textconv driver is
+/// selected from the in-tree `.gitattributes`.
+const EMPTY_TREE_OID: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
+
+/// Parse `(major, minor)` from `git --version` output (e.g. "git version 2.43.0"
+/// or "git version 2.39.3 (Apple Git-145)").
+fn parse_git_version(out: &str) -> Option<(u32, u32)> {
+    let token = out
+        .split_whitespace()
+        .find(|t| t.chars().next().is_some_and(|c| c.is_ascii_digit()))?;
+    let mut parts = token.split('.');
+    let major = parts.next()?.parse().ok()?;
+    let minor = parts.next()?.parse().ok()?;
+    Some((major, minor))
+}
+
+/// Whether the local `git` supports `--attr-source` (added in Git 2.40). Probed
+/// once via `git --version`. When false, the flag is omitted — which is safe
+/// regardless: the corpus call sites never hash working-tree content (the only
+/// trigger for an attribute-selected filter), so `--attr-source` is in-tree
+/// `.gitattributes` defense-in-depth, not the primary control. Omitting it on old
+/// Git therefore keeps the probe BOTH safe AND functional, rather than failing
+/// the whole git signal (passing an unknown flag to git < 2.40 errors out).
+fn attr_source_supported() -> bool {
+    static SUPPORTED: OnceLock = OnceLock::new();
+    *SUPPORTED.get_or_init(|| {
+        Command::new("git")
+            .arg("--version")
+            .output()
+            .ok()
+            .filter(|o| o.status.success())
+            .and_then(|o| String::from_utf8(o.stdout).ok())
+            .and_then(|s| parse_git_version(&s))
+            .is_some_and(|v| v >= (2, 40))
+    })
+}
+
+#[cfg(windows)]
+const NULL_DEVICE: &str = "NUL";
+#[cfg(not(windows))]
+const NULL_DEVICE: &str = "/dev/null";
+
+/// Build a `git` [`Command`] hardened for read-only probes against an untrusted
+/// repository at `repo_root` (sets `git -C `). The caller appends the
+/// subcommand and its arguments, e.g.:
+///
+/// ```no_run
+/// # use std::path::Path;
+/// # use clarion_core::hardened_git_command;
+/// let out = hardened_git_command(Path::new("/corpus"))
+///     .args(["rev-parse", "HEAD"])
+///     .output();
+/// ```
+///
+/// **Callers must not hash working-tree content** on an untrusted corpus (use
+/// `diff --cached`, not `status` or a worktree `diff`) — see the module docs for
+/// why `$GIT_DIR/info/attributes` makes that the call site's responsibility.
+/// `--attr-source` is added only on Git >= 2.40 (probed once); older Git omits it
+/// and is still safe (the `--cached` call sites are the real control).
+pub fn hardened_git_command(repo_root: &Path) -> Command {
+    let mut command = Command::new("git");
+    command
+        .env("GIT_CONFIG_NOSYSTEM", "1")
+        .env("GIT_CONFIG_GLOBAL", NULL_DEVICE)
+        .env("GIT_CONFIG_SYSTEM", NULL_DEVICE)
+        .env("GIT_OPTIONAL_LOCKS", "0")
+        // Ignore the system gitattributes file (the worktree and core.attributesFile
+        // sources are handled by --attr-source and the -c override below).
+        .env("GIT_ATTR_NOSYSTEM", "1")
+        .env_remove("GIT_CONFIG_COUNT")
+        .env_remove("GIT_EXTERNAL_DIFF")
+        .env_remove("GIT_DIFF_OPTS")
+        .env_remove("GIT_ATTR_SOURCE")
+        .env_remove("GIT_PAGER")
+        .arg("-c")
+        .arg("core.fsmonitor=false")
+        .arg("-c")
+        .arg("core.untrackedCache=false")
+        .arg("-c")
+        .arg("diff.external=")
+        .arg("-c")
+        .arg("core.pager=cat")
+        .arg("-c")
+        .arg(format!("core.attributesFile={NULL_DEVICE}"));
+    // Belt-and-suspenders for the in-tree `.gitattributes` source, but only on
+    // Git >= 2.40 (older Git rejects the flag, which would blank the whole
+    // signal). Safe to omit otherwise — see `attr_source_supported`.
+    if attr_source_supported() {
+        command.arg(format!("--attr-source={EMPTY_TREE_OID}"));
+    }
+    command.arg("-C").arg(repo_root);
+    command
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn hardened_command_overrides_repo_controlled_helpers() {
+        let command = hardened_git_command(Path::new("/corpus"));
+        let args: Vec = command
+            .get_args()
+            .map(|a| a.to_string_lossy().into_owned())
+            .collect();
+
+        // `-c` overrides for the program-naming repo-local config keys.
+        assert!(args.windows(2).any(|w| w == ["-c", "core.fsmonitor=false"]));
+        assert!(args.windows(2).any(|w| w == ["-c", "diff.external="]));
+        assert!(
+            args.windows(2)
+                .any(|w| w == ["-c", &format!("core.attributesFile={NULL_DEVICE}")]),
+            "core.attributesFile must be overridden to the null device"
+        );
+        // Attributes read from the empty tree → no in-tree filter is selected.
+        // Present iff the local git supports the flag (>= 2.40); the test machine
+        // determines which branch applies, so gate the assertion on the probe.
+        let has_attr_source = args
+            .iter()
+            .any(|a| a == &format!("--attr-source={EMPTY_TREE_OID}"));
+        assert_eq!(
+            has_attr_source,
+            attr_source_supported(),
+            "--attr-source must be present iff git >= 2.40"
+        );
+        // Operates against the given corpus path.
+        assert!(args.windows(2).any(|w| w == ["-C", "/corpus"]));
+
+        let envs: Vec<(String, Option)> = command
+            .get_envs()
+            .map(|(k, v)| {
+                (
+                    k.to_string_lossy().into_owned(),
+                    v.map(|v| v.to_string_lossy().into_owned()),
+                )
+            })
+            .collect();
+        assert!(envs.contains(&("GIT_CONFIG_NOSYSTEM".to_owned(), Some("1".to_owned()))));
+        assert!(envs.contains(&("GIT_CONFIG_GLOBAL".to_owned(), Some(NULL_DEVICE.to_owned()))));
+        assert!(envs.contains(&("GIT_ATTR_NOSYSTEM".to_owned(), Some("1".to_owned()))));
+        // Inherited env-based config/exec injection is stripped.
+        for removed in ["GIT_CONFIG_COUNT", "GIT_EXTERNAL_DIFF", "GIT_ATTR_SOURCE"] {
+            assert!(
+                envs.iter().any(|(k, v)| k == removed && v.is_none()),
+                "{removed} must be removed from the child environment"
+            );
+        }
+    }
+
+    #[test]
+    fn parse_git_version_extracts_major_minor() {
+        assert_eq!(parse_git_version("git version 2.43.0"), Some((2, 43)));
+        assert_eq!(
+            parse_git_version("git version 2.39.3 (Apple Git-145)"),
+            Some((2, 39))
+        );
+        assert_eq!(
+            parse_git_version("git version 2.40.1.windows.1"),
+            Some((2, 40))
+        );
+        assert_eq!(parse_git_version("garbage"), None);
+    }
+}
diff --git a/crates/clarion-core/src/lib.rs b/crates/clarion-core/src/lib.rs
index 51ff594d..35b858a6 100644
--- a/crates/clarion-core/src/lib.rs
+++ b/crates/clarion-core/src/lib.rs
@@ -9,6 +9,7 @@
 pub mod embedding_provider;
 pub mod entity_id;
 pub mod errors;
+pub mod hardened_git;
 pub mod llm_provider;
 pub mod plugin;
 
@@ -18,6 +19,7 @@ pub use embedding_provider::{
 };
 pub use entity_id::{EntityId, EntityIdError, entity_id};
 pub use errors::{HttpErrorCode, McpErrorCode};
+pub use hardened_git::hardened_git_command;
 pub use llm_provider::{
     CachingModel, ClaudeCliProvider, ClaudeCliProviderConfig, CodexCliProvider,
     CodexCliProviderConfig, INFERRED_CALLS_PROMPT_VERSION, InferredCallsPromptInput,
diff --git a/crates/clarion-mcp/src/index_diff.rs b/crates/clarion-mcp/src/index_diff.rs
index 62981bbc..1e9b24d7 100644
--- a/crates/clarion-mcp/src/index_diff.rs
+++ b/crates/clarion-mcp/src/index_diff.rs
@@ -16,9 +16,9 @@
 
 use std::collections::BTreeSet;
 use std::path::Path;
-use std::process::Command;
 use std::time::SystemTime;
 
+use clarion_core::hardened_git_command;
 use serde_json::{Value, json};
 use time::OffsetDateTime;
 use time::format_description::well_known::Rfc3339;
@@ -48,9 +48,11 @@ struct DirtyEntry {
 /// Run `git` read-only against `project_root` and collect HEAD + dirty-tree
 /// facts. Blocking; call from a `spawn_blocking` context.
 pub(crate) fn gather_git_facts(project_root: &Path) -> GitFacts {
-    let inside = Command::new("git")
-        .arg("-C")
-        .arg(project_root)
+    // Hardened against the untrusted corpus (clarion-4b5a8aff54): no
+    // repo-controlled program runs while gathering git facts. `git status` below
+    // is the most reliable fsmonitor/clean-filter trigger of all, so the
+    // hardening is load-bearing here.
+    let inside = hardened_git_command(project_root)
         .args(["rev-parse", "--is-inside-work-tree"])
         .output();
     let (available, is_repo, reason) = match inside {
@@ -74,9 +76,7 @@ pub(crate) fn gather_git_facts(project_root: &Path) -> GitFacts {
     }
 
     let run = |args: &[&str]| -> Option {
-        let out = Command::new("git")
-            .arg("-C")
-            .arg(project_root)
+        let out = hardened_git_command(project_root)
             .args(args)
             .output()
             .ok()?;
@@ -89,17 +89,23 @@ pub(crate) fn gather_git_facts(project_root: &Path) -> GitFacts {
     let head_commit = run(&["rev-parse", "HEAD"]);
     // `%cI` is strict ISO-8601 (RFC3339) with the committer's UTC offset.
     let head_committed_at = run(&["log", "-1", "--format=%cI", "HEAD"]);
-    // Read status raw: porcelain's leading X-column space is significant, so it
-    // must NOT be trimmed off the front (the `run` closure trims the whole
-    // blob, which would shift every column left and corrupt the path).
-    let dirty = Command::new("git")
-        .arg("-C")
-        .arg(project_root)
-        .args(["status", "--porcelain=v1"])
+    // Dirty signal via `git diff --cached` (STAGED changes, index vs HEAD), NOT
+    // `git status` (clarion-4b5a8aff54): `git status` must hash working-tree
+    // content to report unstaged modifications, which executes a repo-controlled
+    // `filter..clean` selected by `$GIT_DIR/info/attributes` — a source no
+    // config flag can disable on an untrusted corpus. `--cached` compares only
+    // stored objects (index + HEAD), so it never hashes the working tree. The
+    // cost is honest and bounded: unstaged working-tree modifications and
+    // untracked files are NOT enumerated here. Unstaged modifications to *indexed*
+    // files are still caught by the stat-based `compute_file_drift`
+    // (`modified_since_analyze`); only never-staged/never-indexed changes go
+    // unreported, which the report notes already disclaim.
+    let dirty = hardened_git_command(project_root)
+        .args(["diff", "--cached", "--name-status", "-M", "HEAD"])
         .output()
         .ok()
         .filter(|out| out.status.success())
-        .map(|out| parse_porcelain(&String::from_utf8_lossy(&out.stdout)))
+        .map(|out| parse_name_status(&String::from_utf8_lossy(&out.stdout)))
         .unwrap_or_default();
 
     GitFacts {
@@ -112,20 +118,29 @@ pub(crate) fn gather_git_facts(project_root: &Path) -> GitFacts {
     }
 }
 
-/// Parse `git status --porcelain=v1` output into per-path entries. Renames
-/// (`R  old -> new`) collapse to the new path; git's C-style quoting of paths
-/// with special bytes is decoded (see [`unquote_c_path`]).
-fn parse_porcelain(out: &str) -> Vec {
+/// Parse `git diff --cached --name-status -M HEAD` output into per-path entries.
+/// Each line is `\t` (e.g. `M\tsrc/lib.rs`, `A\tnew.py`) or, for
+/// renames/copies, `\t\t` — which collapse to the new path. The
+/// status is reduced to its leading letter (`R096` → `R`). git's C-style quoting
+/// of paths with special bytes is decoded (see [`unquote_c_path`]).
+fn parse_name_status(out: &str) -> Vec {
     out.lines()
         .filter_map(|line| {
-            if line.len() <= 3 {
+            let mut cols = line.split('\t');
+            let raw = cols.next()?;
+            let code = raw.chars().next()?;
+            // Rename/copy lines carry two paths (old, new); report the new path.
+            let path = if matches!(code, 'R' | 'C') {
+                let _old = cols.next()?;
+                cols.next()?
+            } else {
+                cols.next()?
+            };
+            if path.is_empty() {
                 return None;
             }
-            let status = line[..2].trim().to_owned();
-            let rest = line[3..].trim();
-            let path = rest.rsplit(" -> ").next().unwrap_or(rest);
             Some(DirtyEntry {
-                status,
+                status: code.to_string(),
                 rel_path: unquote_c_path(path),
             })
         })
@@ -401,7 +416,9 @@ pub(crate) fn build_report(
         .filter_map(|f| normalize_source_path(project_root, &f.source_file_path).ok())
         .collect();
 
-    // Dirty working-tree files, flagged when they touch an indexed path.
+    // Staged-vs-HEAD changes (clarion-4b5a8aff54: no worktree hashing), flagged
+    // when they touch an indexed path. Unstaged/untracked changes are not in this
+    // set; unstaged edits to indexed files surface via `file_drift` below.
     let mut dirty = Vec::new();
     let mut dirty_indexed_count = 0usize;
     for entry in &git.dirty {
@@ -448,6 +465,11 @@ pub(crate) fn build_report(
         "added (never-indexed) source files are not enumerated here beyond the \
          git dirty set; a new commit still flips head_newer_than_analyze"
             .to_owned(),
+        "dirty_files lists STAGED changes (index vs HEAD); unstaged working-tree \
+         modifications and untracked files are not enumerated (untrusted-corpus \
+         hardening). Unstaged edits to indexed files still surface in \
+         modified_since_analyze."
+            .to_owned(),
     ];
     if file_drift.stat_failures > 0 {
         notes.push(format!(
@@ -512,29 +534,32 @@ mod tests {
     use super::*;
 
     #[test]
-    fn parse_porcelain_handles_modified_and_rename() {
-        let out = " M src/lib.rs\nR  old.rs -> new.rs\n?? untracked.py\n";
-        let entries = parse_porcelain(out);
+    fn parse_name_status_handles_modified_added_and_rename() {
+        // `git diff --cached --name-status -M HEAD` format: tab-separated, with a
+        // second path column for renames/copies.
+        let out = "M\tsrc/lib.rs\nA\tnew.py\nR096\told.rs\tnew.rs\n";
+        let entries = parse_name_status(out);
         assert_eq!(entries.len(), 3);
         assert_eq!(entries[0].status, "M");
         assert_eq!(entries[0].rel_path, "src/lib.rs");
-        assert_eq!(entries[1].status, "R");
-        assert_eq!(entries[1].rel_path, "new.rs");
-        assert_eq!(entries[2].status, "??");
-        assert_eq!(entries[2].rel_path, "untracked.py");
+        assert_eq!(entries[1].status, "A");
+        assert_eq!(entries[1].rel_path, "new.py");
+        assert_eq!(entries[2].status, "R");
+        assert_eq!(entries[2].rel_path, "new.rs");
     }
 
     #[test]
-    fn parse_porcelain_skips_blank_and_short_lines() {
-        assert!(parse_porcelain("\n \nM\n").is_empty());
+    fn parse_name_status_skips_blank_and_malformed_lines() {
+        // Blank lines and a status with no path column yield nothing.
+        assert!(parse_name_status("\n\nM\n").is_empty());
     }
 
     #[test]
-    fn parse_porcelain_decodes_c_quoted_non_ascii_path() {
+    fn parse_name_status_decodes_c_quoted_non_ascii_path() {
         // git quotes `café.py` (and emits its UTF-8 bytes as octal escapes)
         // under the default core.quotePath=true.
-        let out = " M \"caf\\303\\251.py\"\n";
-        let entries = parse_porcelain(out);
+        let out = "M\t\"caf\\303\\251.py\"\n";
+        let entries = parse_name_status(out);
         assert_eq!(entries.len(), 1);
         assert_eq!(entries[0].status, "M");
         assert_eq!(
@@ -746,4 +771,96 @@ mod tests {
         assert_eq!(missing[0]["path"], abs);
         assert_eq!(missing[0]["indexed_entities"], 4);
     }
+
+    /// REGRESSION (clarion-4b5a8aff54): `gather_git_facts` gathers facts against
+    /// an untrusted served corpus. It must not execute any repo-controlled helper
+    /// — not `core.fsmonitor`, and not a `filter..clean` selected by ANY
+    /// attribute source (in-tree `.gitattributes`, `$GIT_DIR/info/attributes`
+    /// which no config flag disables, or `core.attributesFile`). It avoids
+    /// `git status` (which would hash the worktree) in favour of `git diff
+    /// --cached`, so staged changes are still reported.
+    #[cfg(unix)]
+    #[test]
+    fn gather_git_facts_does_not_execute_repo_controlled_helpers() {
+        use std::os::unix::fs::PermissionsExt;
+        use std::process::Command;
+
+        let dir = tempfile::tempdir().unwrap();
+        let repo = dir.path().to_path_buf();
+        // Raw `git` is fine here: it builds the trusted fixture repo. The
+        // assertion below exercises the hardened production path.
+        let run_git = |args: &[&str]| {
+            let out = Command::new("git")
+                .arg("-C")
+                .arg(&repo)
+                .args(args)
+                .output()
+                .expect("git runs");
+            assert!(out.status.success(), "git {args:?} failed");
+        };
+
+        run_git(&["init", "-q"]);
+        run_git(&["config", "user.email", "t@t"]);
+        run_git(&["config", "user.name", "t"]);
+        std::fs::write(repo.join("auth.py"), "def login():\n    return 1\n").unwrap();
+        run_git(&["add", "."]);
+        run_git(&["commit", "-qm", "init"]);
+        // Dirty the tree so `git status` must re-hash working-tree content.
+        run_git(&["mv", "auth.py", "authn.py"]);
+        std::fs::write(repo.join("authn.py"), "def login():\n    return 2\n").unwrap();
+
+        let make_payload = |name: &str, marker: &Path| {
+            let p = repo.join(name);
+            std::fs::write(
+                &p,
+                format!("#!/bin/sh\necho fired > {}\ncat\n", marker.display()),
+            )
+            .unwrap();
+            let mut perms = std::fs::metadata(&p).unwrap().permissions();
+            perms.set_mode(0o755);
+            std::fs::set_permissions(&p, perms).unwrap();
+            p
+        };
+        let fsmonitor_marker = repo.join("FSMONITOR_FIRED");
+        let filter_marker = repo.join("FILTER_FIRED");
+        let fsmonitor_payload = make_payload("fsmonitor.sh", &fsmonitor_marker);
+        let filter_payload = make_payload("filter.sh", &filter_marker);
+
+        run_git(&[
+            "config",
+            "core.fsmonitor",
+            &fsmonitor_payload.display().to_string(),
+        ]);
+        // `filter=evil` assigned from all three attribute sources at once.
+        std::fs::write(repo.join(".gitattributes"), "* filter=evil\n").unwrap();
+        std::fs::write(repo.join(".git/info/attributes"), "* filter=evil\n").unwrap();
+        std::fs::write(repo.join("extra-attrs"), "* filter=evil\n").unwrap();
+        run_git(&[
+            "config",
+            "core.attributesFile",
+            &repo.join("extra-attrs").display().to_string(),
+        ]);
+        run_git(&[
+            "config",
+            "filter.evil.clean",
+            &filter_payload.display().to_string(),
+        ]);
+
+        let facts = gather_git_facts(&repo);
+
+        assert!(
+            !fsmonitor_marker.exists(),
+            "repo-local core.fsmonitor executed during gather_git_facts"
+        );
+        assert!(
+            !filter_marker.exists(),
+            "repo-local filter.*.clean executed during gather_git_facts"
+        );
+        assert!(facts.is_repo, "repo must still be recognized");
+        assert!(
+            facts.dirty.iter().any(|e| e.rel_path == "authn.py"),
+            "dirty reporting must still work; got {:?}",
+            facts.dirty.iter().map(|e| &e.rel_path).collect::>()
+        );
+    }
 }

From 6640cd211335b476e96a8fb3696fb379186a2ac5 Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 15:13:57 +1000
Subject: [PATCH 20/21] Address Wardline descriptor review feedback

---
 plugins/python/src/clarion_plugin_python/server.py | 5 +++--
 plugins/python/tests/test_package.py               | 3 ++-
 2 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/plugins/python/src/clarion_plugin_python/server.py b/plugins/python/src/clarion_plugin_python/server.py
index 5b33e29d..1c335726 100644
--- a/plugins/python/src/clarion_plugin_python/server.py
+++ b/plugins/python/src/clarion_plugin_python/server.py
@@ -14,8 +14,9 @@
 - ``initialized`` / ``exit`` — notifications, no response.
 
 Task 2 shipped the dispatch skeleton with ``analyze_file`` returning an empty
-entity list. The current plugin does not advertise Wardline capabilities until
-it emits real Wardline-derived semantic signals.
+entity list. The current plugin advertises its Wardline descriptor state during
+``initialize`` and emits Wardline-derived semantic signals when a compatible
+descriptor is available.
 """
 
 from __future__ import annotations
diff --git a/plugins/python/tests/test_package.py b/plugins/python/tests/test_package.py
index c430cec5..5ac67ef1 100644
--- a/plugins/python/tests/test_package.py
+++ b/plugins/python/tests/test_package.py
@@ -7,6 +7,7 @@
 from typing import Any
 
 import clarion_plugin_python
+from clarion_plugin_python.wardline_descriptor import EXPECTED_DESCRIPTOR_VERSION
 
 _PLUGIN_ROOT = Path(__file__).resolve().parents[1]
 
@@ -44,7 +45,7 @@ def test_manifest_declares_current_v1_ontology_only() -> None:
     assert manifest["plugin"]["version"] == "1.3.0"
     assert manifest["capabilities"]["runtime"]["wardline_aware"] is True
     assert manifest["integrations"]["wardline"]["expected_descriptor_version"] == (
-        "wardline-generic-2"
+        EXPECTED_DESCRIPTOR_VERSION
     )
     assert manifest["ontology"]["ontology_version"] == "0.7.0"
     assert manifest["ontology"]["entity_kinds"] == ["function", "class", "module"]

From 6e2209d457674e5ed2ff428d49bbe39dd5f145be Mon Sep 17 00:00:00 2001
From: John Morrissey <544926+tachyon-beep@users.noreply.github.com>
Date: Fri, 5 Jun 2026 15:14:08 +1000
Subject: [PATCH 21/21] Fix MCP e2e fixture for release 1.3 policy

---
 tests/e2e/sprint_2_mcp_surface.sh | 83 +++++++++++++++++++++----------
 1 file changed, 56 insertions(+), 27 deletions(-)

diff --git a/tests/e2e/sprint_2_mcp_surface.sh b/tests/e2e/sprint_2_mcp_surface.sh
index 6e1451a5..9c684674 100755
--- a/tests/e2e/sprint_2_mcp_surface.sh
+++ b/tests/e2e/sprint_2_mcp_surface.sh
@@ -124,6 +124,14 @@ world_hash = conn.execute(
     "SELECT content_hash FROM entities WHERE id = ?",
     ("python:function:demo.world",),
 ).fetchone()[0]
+world_sei = conn.execute(
+    "SELECT sei FROM sei_bindings WHERE current_locator = ? AND status = 'alive'",
+    ("python:function:demo.world",),
+).fetchone()[0]
+hello_sei = conn.execute(
+    "SELECT sei FROM sei_bindings WHERE current_locator = ? AND status = 'alive'",
+    ("python:function:demo.hello",),
+).fetchone()[0]
 world_entity = conn.execute(
     """
     SELECT id, kind, name, source_file_path, source_line_start, source_line_end
@@ -182,33 +190,50 @@ filigree_requests: list[str] = []
 class FiligreeHandler(BaseHTTPRequestHandler):
     def do_GET(self) -> None:
         parsed = urllib.parse.urlparse(self.path)
-        if parsed.path != "/api/entity-associations":
+        query = urllib.parse.parse_qs(parsed.query)
+        if parsed.path == "/api/entity-associations":
+            entity_id = query.get("entity_id", [""])[0]
+            filigree_requests.append(entity_id)
+            associations: list[dict[str, str]] = []
+            if entity_id in {"python:function:demo.world", world_sei}:
+                associations.append(
+                    {
+                        "issue_id": "filigree-world",
+                        "clarion_entity_id": entity_id,
+                        "content_hash_at_attach": world_hash,
+                        "attached_at": "2026-05-17T00:00:00.000Z",
+                        "attached_by": "codex",
+                    }
+                )
+            elif entity_id in {"python:function:demo.hello", hello_sei}:
+                associations.append(
+                    {
+                        "issue_id": "filigree-hello-drifted",
+                        "clarion_entity_id": entity_id,
+                        "content_hash_at_attach": "old-hash",
+                        "attached_at": "2026-05-17T00:00:00.000Z",
+                        "attached_by": "codex",
+                    }
+                )
+            body = json.dumps({"associations": associations}).encode("utf-8")
+        elif parsed.path == "/api/loom/files":
+            path_prefix = query.get("path_prefix", [""])[0]
+            items = []
+            if query.get("scan_source", [""])[0] == "wardline" and path_prefix == "demo.py":
+                items = [
+                    {
+                        "file_id": "wardline-demo-py",
+                        "path": "demo.py",
+                        "language": "python",
+                        "file_type": "source",
+                    }
+                ]
+            body = json.dumps({"items": items, "has_more": False}).encode("utf-8")
+        elif parsed.path == "/api/loom/findings":
+            body = json.dumps({"items": [], "has_more": False}).encode("utf-8")
+        else:
             self.send_error(404)
             return
-        entity_id = urllib.parse.parse_qs(parsed.query).get("entity_id", [""])[0]
-        filigree_requests.append(entity_id)
-        associations: list[dict[str, str]] = []
-        if entity_id == "python:function:demo.world":
-            associations.append(
-                {
-                    "issue_id": "filigree-world",
-                    "clarion_entity_id": entity_id,
-                    "content_hash_at_attach": world_hash,
-                    "attached_at": "2026-05-17T00:00:00.000Z",
-                    "attached_by": "codex",
-                }
-            )
-        elif entity_id == "python:function:demo.hello":
-            associations.append(
-                {
-                    "issue_id": "filigree-hello-drifted",
-                    "clarion_entity_id": entity_id,
-                    "content_hash_at_attach": "old-hash",
-                    "attached_at": "2026-05-17T00:00:00.000Z",
-                    "attached_by": "codex",
-                }
-            )
-        body = json.dumps({"associations": associations}).encode("utf-8")
         self.send_response(200)
         self.send_header("content-type", "application/json")
         self.send_header("content-length", str(len(body)))
@@ -231,6 +256,9 @@ llm_policy:
   model_id: anthropic/claude-sonnet-4.6
   session_token_ceiling: 1000000
   recording_fixture_path: .clarion/openrouter-recording.json
+serve:
+  mcp:
+    enable_write_tools: true
 integrations:
   filigree:
     enabled: true
@@ -543,8 +571,9 @@ assert issues["result"]["result_kind"] == "matched", issues
 assert issues["result"]["filigree_endpoint"]["enabled"] is True, issues
 assert issues["result"]["filigree_endpoint"]["resolved_url"], issues
 assert issues["stats_delta"]["filigree_requests_total"] >= 2, issues
-assert "python:function:demo.world" in filigree_requests, filigree_requests
-assert "python:function:demo.hello" in filigree_requests, filigree_requests
+assert world_sei in filigree_requests, filigree_requests
+assert hello_sei in filigree_requests, filigree_requests
+assert issues["result"]["wardline_findings"]["result_kind"] == "no_matches", issues
 
 context = responses["context"]["result"]
 ctx_text = context["contents"][0]["text"]