diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..843a48b1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,105 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + +jobs: + rust: + name: Rust + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt, llvm-tools-preview + + - uses: Swatinem/rust-cache@v2 + + - name: fmt + run: cargo fmt --all -- --check + + - name: clippy + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: install cargo-nextest + uses: taiki-e/install-action@cargo-nextest + + # Ensure all workspace binaries (notably clarion-plugin-fixture) are built + # before nextest runs. wp2_e2e tests need the fixture binary on disk and + # nextest's CARGO_BIN_EXE_* propagation is not reliably set for cross-package + # dev-dep binaries (deferred issue clarion-adeff0916d). + - name: build workspace bins + run: cargo build --workspace --bins + + - name: test + run: cargo nextest run --workspace --all-features --no-tests=pass + + - name: doc + run: cargo doc --workspace --no-deps --all-features + + - name: install cargo-deny + uses: taiki-e/install-action@cargo-deny + + - name: deny + run: cargo deny check + + python-plugin: + name: Python plugin + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: plugins/python/pyproject.toml + + - name: install plugin (dev extras) + run: python -m pip install -e 'plugins/python[dev]' + + - name: ruff check + run: ruff check plugins/python + + - name: ruff format check + run: ruff format --check plugins/python + + - name: mypy + run: mypy --strict plugins/python + + - name: pytest + run: pytest plugins/python + + walking-skeleton: + name: Sprint 1 walking skeleton (end-to-end) + runs-on: ubuntu-latest + needs: [rust, python-plugin] + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: plugins/python/pyproject.toml + + - name: install sqlite3 cli + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends sqlite3 + + - name: run walking skeleton + run: bash tests/e2e/sprint_1_walking_skeleton.sh diff --git a/.gitignore b/.gitignore index f10309c6..9c48d09a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,22 @@ -.filigree/ \ No newline at end of file +.filigree/ +/target +**/*.rs.bk +Cargo.lock.bak + +# SQLite working files (project-level .clarion/ is tracked per ADR-005) +*.db-journal +*.db-wal + +# Rust-analyzer / IDE caches +/.idea +/.vscode + +# Python (plugins/python/) +.venv/ +__pycache__/ +*.egg-info/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..d4158384 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.11 + hooks: + - id: ruff + args: [--fix] + files: ^plugins/python/.*\.py$ + - id: ruff-format + files: ^plugins/python/.*\.py$ + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.20.2 + hooks: + - id: mypy + files: ^plugins/python/(src|tests)/.*\.py$ + # Run mypy against the whole plugin tree (not just staged files in + # isolation) so intra-package imports resolve. The files: pattern + # above triggers the hook; args: names the actual target. + pass_filenames: false + args: [--strict, --config-file=plugins/python/pyproject.toml, plugins/python] + additional_dependencies: + - pytest>=8.0 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..0518a108 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1217 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert_cmd" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clarion-cli" +version = "0.1.0-dev" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "clarion-core", + "clarion-plugin-fixture", + "clarion-storage", + "rusqlite", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "clarion-core" +version = "0.1.0-dev" +dependencies = [ + "nix", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", + "tracing", +] + +[[package]] +name = "clarion-plugin-fixture" +version = "0.1.0-dev" +dependencies = [ + "clarion-core", + "serde_json", +] + +[[package]] +name = "clarion-storage" +version = "0.1.0-dev" +dependencies = [ + "clarion-core", + "deadpool-sqlite", + "rusqlite", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "deadpool-sqlite" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9cc6210316f8b7ced394e2a5d2833ce7097fb28afb5881299c61bc18e8e0e9" +dependencies = [ + "deadpool", + "deadpool-sync", + "rusqlite", +] + +[[package]] +name = "deadpool-sync" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524bc3df0d57e98ecd022e21ba31166c2625e7d3e5bcc4510efaeeab4abcab04" +dependencies = [ + "deadpool-runtime", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..3923ca18 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[workspace] +resolver = "3" +members = [ + "crates/clarion-core", + "crates/clarion-storage", + "crates/clarion-cli", + "crates/clarion-plugin-fixture", +] + +[workspace.package] +version = "0.1.0-dev" +edition = "2024" +license = "MIT OR Apache-2.0" +repository = "https://github.com/qacona/clarion" +rust-version = "1.85" + +[workspace.lints.rust] +# Changed from "forbid" to "deny" to allow the single documented unsafe use: +# `CommandExt::pre_exec` in plugin/host.rs calls `apply_prlimit_as` (which +# calls `setrlimit(2)`, POSIX async-signal-safe) in the forked child. +# All unsafe blocks must carry a SAFETY comment justifying the usage. +unsafe_code = "deny" + +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } +# Pragmatic allows per ADR-023 — revisit per WP if the floor gets too loud. +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" + +[workspace.dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +deadpool-sqlite = { version = "0.8", features = ["rt_tokio_1"] } +rusqlite = { version = "0.31", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4"] } +toml = "0.8" +assert_cmd = "2" +tempfile = "3" +nix = { version = "0.28", default-features = false, features = ["resource"] } diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..38b13c05 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,3 @@ +cognitive-complexity-threshold = 15 +too-many-arguments-threshold = 8 +too-many-lines-threshold = 120 diff --git a/crates/clarion-cli/Cargo.toml b/crates/clarion-cli/Cargo.toml new file mode 100644 index 00000000..97c7e351 --- /dev/null +++ b/crates/clarion-cli/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "clarion-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "clarion" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +clarion-core = { path = "../clarion-core", version = "0.1.0-dev" } +clarion-storage = { path = "../clarion-storage", version = "0.1.0-dev" } +rusqlite.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true + +[dev-dependencies] +assert_cmd.workspace = true +clarion-plugin-fixture = { path = "../clarion-plugin-fixture", version = "0.1.0-dev" } +rusqlite.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/clarion-cli/src/analyze.rs b/crates/clarion-cli/src/analyze.rs new file mode 100644 index 00000000..d9335c32 --- /dev/null +++ b/crates/clarion-cli/src/analyze.rs @@ -0,0 +1,905 @@ +//! `clarion analyze` — discover plugins, walk the source tree, persist entities. +//! +//! WP2 Task 8 replaces the Sprint-1 stub with real plugin orchestration: +//! - Discover plugins via L9 `$PATH` convention (Task 5). +//! - For each plugin: spawn, handshake, walk the source tree, call +//! `analyze_file` for every matching file, persist via writer-actor. +//! - Pattern A buffering: collect entities in the blocking task, flush +//! `InsertEntity` commands from async context after the blocking task returns. +//! - On unrecoverable error (cap, escape, spawn, transport) → `FailRun`. +//! - Zero successful plugins discovered → `SkippedNoPlugins` (existing path). + +use std::collections::BTreeSet; +use std::io; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use uuid::Uuid; + +use clarion_core::{ + AcceptedEntity, CrashLoopBreaker, CrashLoopState, DiscoveredPlugin, + FINDING_DISABLED_CRASH_LOOP, HostError, HostFinding, discover, +}; +use clarion_storage::{ + DEFAULT_BATCH_SIZE, DEFAULT_CHANNEL_CAPACITY, Writer, + commands::{EntityRecord, RunStatus, WriterCmd}, +}; + +// ── Public entry point ──────────────────────────────────────────────────────── + +/// Run the analyze command against `project_path`. +/// +/// # Errors +/// +/// Returns an error if the target directory does not exist, has no `.clarion/` +/// directory, or if the writer actor fails to start or process commands. +#[allow(clippy::too_many_lines)] +pub async fn run(project_path: PathBuf) -> Result<()> { + if !project_path.exists() { + bail!( + "target directory does not exist: {}. Pass a valid path or cd to it first.", + project_path.display() + ); + } + let project_root = project_path + .canonicalize() + .with_context(|| format!("cannot canonicalise path {}", project_path.display()))?; + let clarion_dir = project_root.join(".clarion"); + if !clarion_dir.exists() { + bail!( + "{} has no .clarion/ directory. Run `clarion install` first.", + project_root.display() + ); + } + let db_path = clarion_dir.join("clarion.db"); + + // ── Writer actor ────────────────────────────────────────────────────────── + let (writer, handle) = Writer::spawn(db_path, DEFAULT_BATCH_SIZE, DEFAULT_CHANNEL_CAPACITY) + .map_err(|e| anyhow::anyhow!("{e}")) + .context("spawn writer actor")?; + let run_id = Uuid::new_v4().to_string(); + let started_at = iso8601_now(); + + writer + .send_wait(|ack| WriterCmd::BeginRun { + run_id: run_id.clone(), + config_json: "{}".into(), + started_at: started_at.clone(), + ack, + }) + .await + .map_err(|e| anyhow::anyhow!("{e}")) + .context("BeginRun")?; + + // ── Discover plugins ────────────────────────────────────────────────────── + let discovery_results = discover(); + let mut plugins: Vec = Vec::new(); + let mut discovery_errors: Vec = Vec::new(); + for result in discovery_results { + match result { + Ok(p) => { + tracing::info!( + plugin_id = %p.manifest.plugin.plugin_id, + executable = %p.executable.display(), + "discovered plugin" + ); + plugins.push(p); + } + Err(e) => { + let msg = e.to_string(); + tracing::warn!(error = %msg, "skipping plugin: discovery error"); + discovery_errors.push(msg); + } + } + } + + if plugins.is_empty() { + // Distinguish "no plugins installed" (SkippedNoPlugins — expected on a + // bare machine) from "plugins present but all failed discovery" (FailRun + // — a real configuration error the operator must see). Reporting the + // latter as `skipped_no_plugins` hides bugs. + if !discovery_errors.is_empty() { + let reason = format!( + "all {} discovered plugin manifest(s) failed to parse: {}", + discovery_errors.len(), + discovery_errors.join("; ") + ); + tracing::error!(run_id = %run_id, reason = %reason, "failing run: discovery errors"); + let completed_at = iso8601_now(); + writer + .send_wait(|ack| WriterCmd::FailRun { + run_id: run_id.clone(), + reason: reason.clone(), + completed_at, + ack, + }) + .await + .map_err(|e| anyhow::anyhow!("{e}")) + .context("FailRun(discovery errors)")?; + + drop(writer); + handle + .await + .map_err(|e| anyhow::anyhow!("writer actor panic: {e}"))? + .map_err(|e| anyhow::anyhow!("{e}"))?; + + // Non-zero exit. Printing to stdout + returning Ok(()) here + // hides the failure from `clarion analyze && do_next` chains + // and breaks CI gating that reads `$?`. The run row in the DB + // is already marked `failed` above. + bail!("analyze run {run_id} failed — {reason}"); + } + + tracing::warn!(run_id = %run_id, "no plugins discovered"); + let completed_at = iso8601_now(); + writer + .send_wait(|ack| WriterCmd::CommitRun { + run_id: run_id.clone(), + status: RunStatus::SkippedNoPlugins, + completed_at: completed_at.clone(), + stats_json: r#"{"entities_inserted":0}"#.into(), + ack, + }) + .await + .map_err(|e| anyhow::anyhow!("{e}")) + .context("CommitRun(SkippedNoPlugins)")?; + + drop(writer); + handle + .await + .map_err(|e| anyhow::anyhow!("writer actor panic: {e}"))? + .map_err(|e| anyhow::anyhow!("{e}"))?; + + println!("analyze complete: run {run_id} skipped_no_plugins"); + return Ok(()); + } + + // ── Build extension union for the tree walk ─────────────────────────────── + let mut wanted_extensions: BTreeSet = BTreeSet::new(); + for p in &plugins { + for ext in &p.manifest.plugin.extensions { + wanted_extensions.insert(ext.to_ascii_lowercase()); + } + } + + // ── Walk the source tree (once, union of all extensions) ───────────────── + let source_files = collect_source_files(&project_root, &wanted_extensions) + .with_context(|| format!("walking source tree at {}", project_root.display()))?; + tracing::info!(file_count = source_files.len(), "source tree walk complete"); + + // ── Per-plugin processing ───────────────────────────────────────────────── + // + // A per-plugin crash (spawn / handshake / analyze_file Err) does NOT tank + // the whole run — other plugins still get a chance. Crashes are recorded + // on the shared `CrashLoopBreaker`; once >3 in 60 s the breaker trips, + // the host emits `FINDING_DISABLED_CRASH_LOOP`, and remaining plugins are + // skipped. A run with any crashes still resolves to `RunOutcome::Failed` + // (plus exit 1 per the bail!() below) so CI sees the problem — continue- + // past-crash preserves partial work, not failure signal. + // + // Writer-actor errors (InsertEntity rejected) ARE run-fatal: the DB + // layer is unusable for the rest of this run. + let mut total_entity_count: u64 = 0; + let mut run_outcome: RunOutcome = RunOutcome::Completed; + let mut breaker = CrashLoopBreaker::default(); + let mut crash_reasons: Vec = Vec::new(); + + 'plugins: for plugin in plugins { + let plugin_id = plugin.manifest.plugin.plugin_id.clone(); + let plugin_extensions: BTreeSet = plugin + .manifest + .plugin + .extensions + .iter() + .map(|e| e.to_ascii_lowercase()) + .collect(); + + // Filter source files to this plugin's extensions. + let plugin_files: Vec = source_files + .iter() + .filter(|p| { + p.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| plugin_extensions.contains(&e.to_ascii_lowercase())) + }) + .cloned() + .collect(); + + if plugin_files.is_empty() { + tracing::info!(plugin_id = %plugin_id, "no files match plugin extensions; skipping"); + continue; + } + + tracing::info!( + plugin_id = %plugin_id, + file_count = plugin_files.len(), + "processing plugin" + ); + + // Run the blocking plugin work on the tokio threadpool. + // Pattern A: collect all entities into memory, return to async side. + let manifest = plugin.manifest.clone(); + let project_root_clone = project_root.clone(); + let pid_clone = plugin_id.clone(); + let exec_clone = plugin.executable.clone(); + let files_clone = plugin_files.clone(); + + // A JoinError here means the blocking task panicked (OOM, stack + // overflow, internal unwrap, abort — anything that unwinds past the + // top of `run_plugin_blocking`). Earlier revisions `?`-propagated + // the JoinError out of `run()`, which bypassed the + // CommitRun/FailRun block and left `runs.status = 'running'` + // permanently. Treat the panic as a crash reason: it flows into the + // existing crash-recording path below, ticks the crash-loop breaker, + // and resolves the run via SoftFailed → CommitRun(Failed) with exit 1. + let spawn_result: Result = handle_plugin_task_join_result( + tokio::task::spawn_blocking(move || { + run_plugin_blocking( + manifest, + &project_root_clone, + &pid_clone, + &exec_clone, + &files_clone, + ) + }) + .await, + &plugin_id, + ); + + match spawn_result { + Err(reason) => { + tracing::warn!( + plugin_id = %plugin_id, + reason = %reason, + "plugin crashed; recording crash and continuing to next plugin", + ); + crash_reasons.push(format!("{plugin_id}: {reason}")); + let state = breaker.record_crash(); + if state == CrashLoopState::Tripped { + tracing::warn!( + subcode = FINDING_DISABLED_CRASH_LOOP, + crash_count = crash_reasons.len(), + "crash-loop breaker tripped; skipping remaining plugins in this run", + ); + break 'plugins; + } + // Fall through to the next iteration — nothing else to do + // for a crashed plugin, and there's no code after the match. + } + Ok(BatchResult { entities, findings }) => { + // Log findings individually (Tier B persistence is future + // work). Logging only the count leaves operators guessing + // whether the plugin tripped an ontology check, emitted + // malformed JSON, or hit a path-jail violation. + if !findings.is_empty() { + tracing::warn!( + plugin_id = %plugin_id, + finding_count = findings.len(), + "plugin host collected findings" + ); + for f in &findings { + tracing::warn!( + plugin_id = %plugin_id, + subcode = %f.subcode, + message = %f.message, + metadata = ?f.metadata, + "plugin host finding", + ); + } + } + + // Persist entities via writer-actor (async side). + // + // A writer-actor error here (e.g. unique-key constraint, disk full) + // must NOT short-circuit `run()` via `?` — that would bypass the + // CommitRun/FailRun block below and leave `runs.status = 'running'` + // permanently. Instead we convert the error to a terminal + // `RunOutcome::Failed` so the FailRun path marks the run. + let count = entities.len() as u64; + let mut insert_err: Option = None; + for (id_str, record) in entities { + let res = writer + .send_wait(|ack| WriterCmd::InsertEntity { + entity: Box::new(record), + ack, + }) + .await + .map_err(|e| anyhow::anyhow!("{e}")) + .with_context(|| format!("InsertEntity for {id_str}")); + if let Err(e) = res { + insert_err = Some(e); + break; + } + } + if let Some(e) = insert_err { + tracing::error!( + plugin_id = %plugin_id, + error = %e, + "writer-actor rejected InsertEntity; failing run" + ); + run_outcome = RunOutcome::HardFailed { + reason: format!("{e:#}"), + }; + break 'plugins; + } + total_entity_count += count; + tracing::info!(plugin_id = %plugin_id, entity_count = count, "plugin complete"); + } + } + } + + // ── Commit or fail the run ──────────────────────────────────────────────── + // + // Writer-actor failures set `run_outcome = HardFailed` above (and break). + // If only plugin crashes occurred (no writer-actor failure), `run_outcome` + // is still `Completed` — promote it to `SoftFailed` so the pending entity + // batch commits AND the run row marks failed. Crash-free completions + // stay `Completed` regardless of entity count. + if matches!(run_outcome, RunOutcome::Completed) && !crash_reasons.is_empty() { + run_outcome = RunOutcome::SoftFailed { + reason: format!( + "{} plugin(s) crashed: {}", + crash_reasons.len(), + crash_reasons.join("; "), + ), + }; + } + + let completed_at = iso8601_now(); + // Extract the failure reason (if any) before the match consumes run_outcome. + let fail_reason: Option = match &run_outcome { + RunOutcome::SoftFailed { reason } | RunOutcome::HardFailed { reason } => { + Some(reason.clone()) + } + RunOutcome::Completed => None, + }; + + match run_outcome { + RunOutcome::Completed => { + let stats_json = format!(r#"{{"entities_inserted":{total_entity_count}}}"#); + writer + .send_wait(|ack| WriterCmd::CommitRun { + run_id: run_id.clone(), + status: RunStatus::Completed, + completed_at, + stats_json, + ack, + }) + .await + .map_err(|e| anyhow::anyhow!("{e}")) + .context("CommitRun(Completed)")?; + } + RunOutcome::SoftFailed { reason } => { + // Commit entities inserted by healthy plugins AND mark the run + // failed, atomically (writer folds the UPDATE into the open tx). + // The stats JSON carries both fields so operators can see what + // was persisted alongside the failure reason. + let stats_json = serde_json::json!({ + "entities_inserted": total_entity_count, + "failure_reason": reason, + }) + .to_string(); + writer + .send_wait(|ack| WriterCmd::CommitRun { + run_id: run_id.clone(), + status: RunStatus::Failed, + completed_at, + stats_json, + ack, + }) + .await + .map_err(|e| anyhow::anyhow!("{e}")) + .context("CommitRun(Failed) — soft fail")?; + } + RunOutcome::HardFailed { reason } => { + writer + .send_wait(|ack| WriterCmd::FailRun { + run_id: run_id.clone(), + reason, + completed_at, + ack, + }) + .await + .map_err(|e| anyhow::anyhow!("{e}")) + .context("FailRun — hard fail")?; + } + } + + drop(writer); + handle + .await + .map_err(|e| anyhow::anyhow!("writer actor panic: {e}"))? + .map_err(|e| anyhow::anyhow!("{e}"))?; + + // On FailRun: bail so the process exits non-zero. The run row is + // already marked `failed` in the DB by the FailRun branch above; this + // is purely about surfacing failure to the operator's shell / CI. + if let Some(reason) = fail_reason { + bail!("analyze run {run_id} failed — {reason}"); + } + + println!("analyze complete: run {run_id} completed ({total_entity_count} entities)"); + Ok(()) +} + +// ── Run-outcome ─────────────────────────────────────────────────────────────── +// +// Three terminal states because plugin crashes and writer-actor failures need +// different SQL paths: +// +// - `Completed`: all plugins ran cleanly → `CommitRun(Completed)`. +// - `SoftFailed`: one or more plugins crashed, but other plugins produced +// entities that should persist → `CommitRun(Failed)`. The writer folds +// `UPDATE runs ... status='failed'` into the open entity transaction so +// the batch commits and the run row marks failed atomically. Exit 1. +// - `HardFailed`: writer-actor rejected an `InsertEntity` (DB locked, disk +// full, etc.) → `FailRun`. The writer rolls back the open transaction. +// Exit 1. Continuing past this makes no sense — the DB is unusable. + +#[derive(Debug)] +enum RunOutcome { + Completed, + SoftFailed { reason: String }, + HardFailed { reason: String }, +} + +// ── JoinError handling ──────────────────────────────────────────────────────── + +/// Convert a `spawn_blocking` join result into the plugin-crash-shaped +/// `Result` the caller already knows how to handle. +/// +/// The `Err(JoinError)` arm is the load-bearing one: a panic inside +/// `run_plugin_blocking` would otherwise `?`-propagate past the run-outcome +/// machinery and leave `runs.status = 'running'` forever. By normalising the +/// panic into a crash reason string, it feeds the existing crash-recording +/// path (ticks the crash-loop breaker, resolves to `SoftFailed` if no writer +/// error occurred). +fn handle_plugin_task_join_result( + result: Result, tokio::task::JoinError>, + plugin_id: &str, +) -> Result { + match result { + Ok(inner) => inner, + Err(join_err) => { + tracing::error!( + plugin_id = %plugin_id, + error = %join_err, + "plugin task panicked; recording as crash", + ); + Err(format!("plugin task for {plugin_id} panicked: {join_err}")) + } + } +} + +// ── Blocking plugin worker ──────────────────────────────────────────────────── + +/// Returned from the blocking plugin task on success. +struct BatchResult { + /// `(entity_id_string, record)` pairs for every accepted entity. + entities: Vec<(String, EntityRecord)>, + /// Findings accumulated by the host during the session. + findings: Vec, +} + +/// Spawn the plugin, handshake, run `analyze_file` for each file, collect results. +/// +/// All I/O is synchronous — this is designed to run inside `spawn_blocking`. +/// On unrecoverable error, returns `Err(reason_string)`. +/// +/// Regardless of success or failure the child process is always reaped: on +/// the happy path via `host.shutdown()` + `child.wait()`, on the error path +/// via `child.kill()` + `child.wait()`. `std::process::Child::Drop` does NOT +/// kill or reap on Unix, so discarding `child` without `wait()` would leak a +/// zombie into the kernel process table per spawn. +fn run_plugin_blocking( + manifest: clarion_core::Manifest, + project_root: &Path, + plugin_id: &str, + executable: &Path, + files: &[PathBuf], +) -> Result { + use clarion_core::PluginHost; + + let (mut host, mut child) = + PluginHost::spawn(manifest, project_root, executable).map_err(|e| match e { + HostError::Spawn(msg) => format!("failed to spawn plugin {plugin_id}: {msg}"), + HostError::Handshake(ref me) => { + format!("plugin {plugin_id} refused handshake: {me}") + } + other => format!("plugin {plugin_id} spawn/handshake error: {other}"), + })?; + + let work_result: Result, String> = (|| { + let mut collected: Vec<(String, EntityRecord)> = Vec::new(); + for file in files { + let entities: Vec = host + .analyze_file(file) + .map_err(|e| classify_host_error(plugin_id, e))?; + for entity in entities { + let id_str = entity.id.to_string(); + let record = map_entity_to_record(&entity, plugin_id); + collected.push((id_str, record)); + } + } + Ok(collected) + })(); + + // Try a graceful shutdown on the happy path; on error, skip straight to + // kill — the plugin's behaviour is already untrusted. `analyze_file` + // already issues `shutdown`/`exit` before returning PathEscapeBreaker or + // EntityCap errors, so calling `host.shutdown()` again there would write + // to a closed pipe; that's why we only call it on Ok. + if work_result.is_ok() { + if let Err(e) = host.shutdown() { + tracing::warn!( + plugin_id = %plugin_id, + error = %e, + "best-effort host shutdown failed; falling back to kill()", + ); + let _ = child.kill(); + } + } else { + let _ = child.kill(); + } + + let mut findings = host.take_findings(); + + // Reap unconditionally. `Child::Drop` does not wait on Unix. + reap_and_classify_exit(&mut child, plugin_id, &mut findings); + + match work_result { + Ok(collected) => Ok(BatchResult { + entities: collected, + findings, + }), + Err(reason) => Err(reason), + } +} + +/// Wait on the child, inspect its exit status, and append an OOM finding if +/// the signal is consistent with `RLIMIT_AS` enforcement (ADR-021 §2d). +/// +/// Linux kernel behaviour on `RLIMIT_AS` violation varies: typical signatures +/// are SIGKILL (OOM-killer path) and SIGSEGV (map/allocation failure that the +/// plugin did not handle). Both are treated as likely memory-limit events. +/// Other signals or non-zero exit codes get a warn log but no finding — the +/// cause is ambiguous without more bookkeeping. +fn reap_and_classify_exit( + child: &mut std::process::Child, + plugin_id: &str, + findings: &mut Vec, +) { + match child.wait() { + Ok(status) if !status.success() => { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + tracing::warn!( + plugin_id = %plugin_id, + signal, + "plugin terminated by signal", + ); + // SIGKILL (9) and SIGSEGV (11) are the observed signatures + // of an RLIMIT_AS kill in Sprint-1 testing. + if signal == 9 || signal == 11 { + findings.push(HostFinding::oom_killed(plugin_id, signal)); + } + } else if let Some(code) = status.code() { + tracing::warn!( + plugin_id = %plugin_id, + code, + "plugin exited non-zero", + ); + } + } + #[cfg(not(unix))] + { + tracing::warn!( + plugin_id = %plugin_id, + "plugin exited non-successfully (exit-status inspection is Unix-only)", + ); + } + } + Ok(_) => {} // clean exit + Err(e) => { + tracing::warn!( + plugin_id = %plugin_id, + error = %e, + "failed to wait on plugin child", + ); + } + } +} + +/// Map a `HostError` from `analyze_file` to a human-readable fail-run reason. +fn classify_host_error(plugin_id: &str, e: HostError) -> String { + match e { + HostError::EntityCapExceeded(_) => { + format!("plugin {plugin_id} exceeded entity-count cap") + } + HostError::PathEscapeBreakerTripped => { + format!("plugin {plugin_id} tripped path-escape breaker") + } + HostError::Spawn(msg) => { + format!("failed to spawn plugin {plugin_id}: {msg}") + } + HostError::Handshake(ref me) => { + format!("plugin {plugin_id} refused handshake: {me}") + } + HostError::Transport(ref te) => { + format!("plugin {plugin_id} transport/protocol error: {te}") + } + HostError::Protocol(ref pe) => { + format!( + "plugin {plugin_id} transport/protocol error: code={}, message={}", + pe.code, pe.message + ) + } + other => format!("plugin {plugin_id} error: {other}"), + } +} + +/// Map an `AcceptedEntity` to an `EntityRecord` for the writer-actor. +fn map_entity_to_record(entity: &AcceptedEntity, plugin_id: &str) -> EntityRecord { + let short_name = entity + .qualified_name + .rsplit('.') + .next() + .unwrap_or(&entity.qualified_name) + .to_owned(); + + let properties_json = + serde_json::to_string(&entity.raw.extra).unwrap_or_else(|_| "{}".to_owned()); + + let now = iso8601_now(); + + EntityRecord { + id: entity.id.to_string(), + plugin_id: plugin_id.to_owned(), + kind: entity.kind.clone(), + name: entity.qualified_name.clone(), + short_name, + parent_id: None, + source_file_id: None, + source_byte_start: None, + source_byte_end: None, + source_line_start: None, + source_line_end: None, + properties_json, + content_hash: None, + summary_json: None, + wardline_json: None, + first_seen_commit: None, + last_seen_commit: None, + created_at: now.clone(), + updated_at: now, + } +} + +// ── Source-tree walk ────────────────────────────────────────────────────────── + +/// Skip-list for directory names during the source walk. +/// +/// Sprint 1 conservative set: VCS directories, clarion's own state, and +/// common virtual-environment directories. +const SKIP_DIRS: &[&str] = &[ + ".clarion", + ".git", + ".hg", + ".svn", + ".jj", + ".venv", + "__pycache__", + "node_modules", +]; + +/// Collect all source files under `root` whose extension is in `wanted`. +/// +/// Uses `std::fs::read_dir` recursively. No `walkdir` dependency. +/// Symlinks are skipped (path-jail concerns for Sprint 1). +/// P4 observation: this does not respect `.gitignore`. +/// +/// Per-entry I/O errors (a dirent we couldn't stat, a file whose +/// `file_type()` probe failed) are logged at `warn` level and counted. +/// When the walk completes with non-zero skips, a summary is logged so +/// the operator can see that the file list is incomplete — silently +/// dropping those entries would mask the same "incomplete analysis" +/// class that the WP1 `read_applied_versions` `.ok()` pattern did. +fn collect_source_files( + root: &Path, + wanted_extensions: &BTreeSet, +) -> io::Result> { + let mut out = Vec::new(); + let mut skipped: u64 = 0; + walk_dir(root, &mut out, &mut skipped, wanted_extensions)?; + if skipped > 0 { + tracing::warn!( + skipped = skipped, + root = %root.display(), + "source walk skipped {skipped} unreadable entr{suffix}; analysis is \ + incomplete for those paths", + suffix = if skipped == 1 { "y" } else { "ies" }, + ); + } + Ok(out) +} + +fn walk_dir( + dir: &Path, + out: &mut Vec, + skipped: &mut u64, + wanted: &BTreeSet, +) -> io::Result<()> { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => return Ok(()), + Err(e) => return Err(e), + }; + + for entry_result in entries { + let entry = match entry_result { + Ok(entry) => entry, + Err(e) => { + tracing::warn!( + error = %e, + dir = %dir.display(), + "source walk: skipping unreadable directory entry", + ); + *skipped += 1; + continue; + } + }; + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(e) => { + tracing::warn!( + error = %e, + path = %entry.path().display(), + "source walk: skipping entry whose file_type() probe failed", + ); + *skipped += 1; + continue; + } + }; + + // Skip symlinks (path-jail concerns). + if file_type.is_symlink() { + continue; + } + + let path = entry.path(); + + if file_type.is_dir() { + // Skip directories in the skip-list. + let dir_name = entry.file_name(); + let name_str = dir_name.to_string_lossy(); + if SKIP_DIRS.iter().any(|skip| *skip == name_str.as_ref()) { + continue; + } + walk_dir(&path, out, skipped, wanted)?; + } else if file_type.is_file() { + // Check extension (case-insensitive compare; `wanted` is already lowercase). + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + let ext_lower = ext.to_ascii_lowercase(); + if wanted.contains(&ext_lower) { + out.push(path); + } + } + } + } + + Ok(()) +} + +// ── Time helpers ────────────────────────────────────────────────────────────── + +/// Format `SystemTime::now()` as an `ISO-8601` UTC string with millisecond +/// precision (`YYYY-MM-DDTHH:MM:SS.sssZ`). +/// +/// Inline rather than depending on `chrono` — Sprint 1 only needs this one +/// formatting pattern. Later WPs that want richer time handling can +/// promote `chrono` to a workspace dependency at that point. +fn iso8601_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let d = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("SystemTime before UNIX epoch"); + let secs = d.as_secs(); + let millis = d.subsec_millis(); + let (y, mo, da, h, mi, se) = civil_from_unix_secs(secs); + format!("{y:04}-{mo:02}-{da:02}T{h:02}:{mi:02}:{se:02}.{millis:03}Z") +} + +/// Convert a non-negative Unix timestamp (seconds since 1970-01-01 UTC) +/// into `(year, month, day, hour, minute, second)`. +/// +/// Algorithm: Howard Hinnant's date, `civil_from_days`. Works for any date +/// from the Unix epoch forward. Does not account for leap seconds (none +/// of our timestamps need leap-second precision). +fn civil_from_unix_secs(mut secs: u64) -> (u32, u32, u32, u32, u32, u32) { + let se = u32::try_from(secs % 60).expect("modulo 60 fits in u32"); + secs /= 60; + let mi = u32::try_from(secs % 60).expect("modulo 60 fits in u32"); + secs /= 60; + let h = u32::try_from(secs % 24).expect("modulo 24 fits in u32"); + secs /= 24; + + // secs is now days since the Unix epoch (1970-01-01). + // Howard Hinnant's algorithm needs days shifted to 0000-03-01 epoch. + let days = i64::try_from(secs).expect("days since epoch fits in i64"); + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = u64::try_from(z - era * 146_097).expect("day-of-era is non-negative"); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y_shifted = i64::try_from(yoe).expect("year-of-era fits in i64") + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let da = u32::try_from(doy - (153 * mp + 2) / 5 + 1).expect("day-of-month fits in u32"); + let mo = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).expect("month fits in u32"); + let y_i64 = if mo <= 2 { y_shifted + 1 } else { y_shifted }; + let y = u32::try_from(y_i64).expect("year fits in u32 (post-1970)"); + (y, mo, da, h, mi, se) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── handle_plugin_task_join_result ──────────────────────────────────────── + // + // Covers the JoinError-bypass regression filed as clarion-cf17e4e779. The + // production path is a `spawn_blocking`-wrapped call to + // `run_plugin_blocking`; if the task panics, `spawn_blocking` yields + // `Err(JoinError)`. Earlier code `?`-propagated that error out of `run()`, + // bypassing the CommitRun/FailRun block and leaving `runs.status = + // 'running'`. The helper converts the panic into a crash reason string so + // the existing crash-recording path handles it. + + #[test] + fn handle_task_passes_through_ok_ok() { + let br = BatchResult { + entities: Vec::new(), + findings: Vec::new(), + }; + let out = handle_plugin_task_join_result(Ok(Ok(br)), "python"); + assert!(out.is_ok()); + } + + #[test] + fn handle_task_passes_through_ok_err() { + let out = + handle_plugin_task_join_result(Ok(Err("spawn failed: ENOENT".to_owned())), "python"); + match out { + Err(s) => assert_eq!(s, "spawn failed: ENOENT"), + Ok(_) => panic!("expected Err pass-through"), + } + } + + #[tokio::test] + async fn handle_task_real_spawn_blocking_panic_is_converted() { + // Drive a real JoinError through the helper by panicking inside + // spawn_blocking. Asserting on the structure-of-Err (not the exact + // message) so this stays robust across tokio's internal formatting. + let join_result = tokio::task::spawn_blocking(|| -> Result { + panic!("simulated plugin-task panic"); + }) + .await; + assert!( + join_result.is_err(), + "spawn_blocking should surface panic as JoinError" + ); + let out = handle_plugin_task_join_result(join_result, "python"); + match out { + Err(s) => { + assert!( + s.contains("plugin task for python panicked"), + "reason must identify plugin_id; got: {s}" + ); + } + Ok(_) => panic!("JoinError must convert to Err, not Ok"), + } + } +} diff --git a/crates/clarion-cli/src/cli.rs b/crates/clarion-cli/src/cli.rs new file mode 100644 index 00000000..e2786cc2 --- /dev/null +++ b/crates/clarion-cli/src/cli.rs @@ -0,0 +1,32 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "clarion", version, about = "Clarion code-archaeology tool")] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Subcommand)] +pub enum Command { + /// Initialise .clarion/ in the current directory. + Install { + /// Overwrite an existing .clarion/ (not implemented in Sprint 1). + #[arg(long)] + force: bool, + + /// Directory to install into (default: current directory). + #[arg(long, default_value = ".")] + path: PathBuf, + }, + + /// Run an analysis pass. Sprint 1: no plugins are loaded; run status is + /// `skipped_no_plugins`. WP2 wires plugin spawning. + Analyze { + /// Path to analyse (default: current directory). + #[arg(default_value = ".")] + path: PathBuf, + }, +} diff --git a/crates/clarion-cli/src/install.rs b/crates/clarion-cli/src/install.rs new file mode 100644 index 00000000..a57f4ac0 --- /dev/null +++ b/crates/clarion-cli/src/install.rs @@ -0,0 +1,130 @@ +//! `clarion install` — initialise .clarion/ in the target directory. +//! +//! Creates: +//! - `.clarion/clarion.db` (migrated) +//! - `.clarion/config.json` (internal state stub) +//! - `.clarion/.gitignore` (UQ-WP1-04 rules; ADR-005) +//! - `/clarion.yaml` (user-edited config stub at project root; +//! see detailed-design.md §File layout) +//! +//! Refuses if `.clarion/` already exists (UQ-WP1-08). `--force` is accepted +//! by the CLI but currently returns an error — Sprint 1 does not implement +//! overwrite. + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use rusqlite::Connection; + +use clarion_storage::{pragma, schema}; + +const CONFIG_JSON_STUB: &str = r#"{ + "schema_version": 1, + "last_run_id": null +} +"#; + +const CLARION_YAML_STUB: &str = "# clarion.yaml — user-edited config.\n\ +# Full schema TBD; see docs/clarion/v0.1 design. Sprint 1 walking skeleton\n\ +# ignores most fields. Do not delete this file: later versions will require\n\ +# it for model-tier mappings and analysis knobs.\n\ +version: 1\n"; + +const GITIGNORE_CONTENTS: &str = "\ +# Clarion .gitignore — ADR-005 tracked-vs-excluded list. +# Tracked (committed): clarion.db, config.json, .gitignore itself. +# Excluded (ignored): WAL sidecars, shadow DB, per-run logs, tmp scratch. + +# SQLite write-ahead files never belong in the repo. +*-wal +*-shm +*.db-wal +*.db-shm + +# Shadow DB intermediate (ADR-011 --shadow-db). +*.shadow.db +*.db.new + +# Scratch / temp space. +tmp/ + +# Per-run log directories (see detailed-design §File layout). The run dir +# metadata (config.yaml, stats.json, partial.json) is tracked; only the +# raw LLM request/response log is excluded. +logs/ +runs/*/log.jsonl +"; + +/// Run the `install` subcommand. +/// +/// # Errors +/// +/// Returns an error if `--force` is passed (not implemented in Sprint 1), +/// if `.clarion/` already exists, if the target directory cannot be +/// canonicalised, or if any filesystem or database operation fails. +pub fn run(path: &Path, force: bool) -> Result<()> { + if force { + bail!( + "--force is not implemented in Sprint 1. Remove .clarion/ manually \ + if you need a clean reinit." + ); + } + + if !path.exists() { + bail!( + "target directory does not exist: {}. Create it first or pass a valid --path.", + path.display() + ); + } + let project_root = path + .canonicalize() + .with_context(|| format!("cannot canonicalise --path {}", path.display()))?; + let clarion_dir = project_root.join(".clarion"); + if clarion_dir.exists() { + bail!( + ".clarion/ already exists at {}. Delete it (or pass --force when \ + Sprint 2+ implements overwrite) and try again.", + clarion_dir.display() + ); + } + + fs::create_dir_all(&clarion_dir).with_context(|| format!("mkdir {}", clarion_dir.display()))?; + + let db_path = clarion_dir.join("clarion.db"); + initialise_db(&db_path).context("initialise clarion.db")?; + + let config_path = clarion_dir.join("config.json"); + fs::write(&config_path, CONFIG_JSON_STUB) + .with_context(|| format!("write {}", config_path.display()))?; + + let gitignore_path = clarion_dir.join(".gitignore"); + fs::write(&gitignore_path, GITIGNORE_CONTENTS) + .with_context(|| format!("write {}", gitignore_path.display()))?; + + let yaml_path = project_root.join("clarion.yaml"); + if yaml_path.exists() { + tracing::debug!( + path = %yaml_path.display(), + "clarion.yaml already exists; leaving untouched" + ); + } else { + fs::write(&yaml_path, CLARION_YAML_STUB) + .with_context(|| format!("write {}", yaml_path.display()))?; + } + + tracing::info!( + clarion_dir = %clarion_dir.display(), + "clarion install complete" + ); + println!("Initialised {}", clarion_dir.display()); + Ok(()) +} + +fn initialise_db(path: &Path) -> Result<()> { + let mut conn = + Connection::open(path).with_context(|| format!("open database {}", path.display()))?; + pragma::apply_write_pragmas(&conn).map_err(|e| anyhow::anyhow!("{e}"))?; + schema::apply_migrations(&mut conn).map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(()) +} diff --git a/crates/clarion-cli/src/main.rs b/crates/clarion-cli/src/main.rs new file mode 100644 index 00000000..4eb89f70 --- /dev/null +++ b/crates/clarion-cli/src/main.rs @@ -0,0 +1,29 @@ +mod analyze; +mod cli; +mod install; + +use anyhow::Result; +use clap::Parser; + +fn main() -> Result<()> { + init_tracing(); + let cli = cli::Cli::parse(); + match cli.command { + cli::Command::Install { force, path } => install::run(&path, force), + cli::Command::Analyze { path } => { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + rt.block_on(analyze::run(path)) + } + } +} + +fn init_tracing() { + use tracing_subscriber::EnvFilter; + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .init(); +} diff --git a/crates/clarion-cli/tests/analyze.rs b/crates/clarion-cli/tests/analyze.rs new file mode 100644 index 00000000..a00d11bd --- /dev/null +++ b/crates/clarion-cli/tests/analyze.rs @@ -0,0 +1,132 @@ +//! `clarion analyze` Sprint-1 integration test. + +use assert_cmd::Command; +use rusqlite::Connection; + +fn clarion_bin() -> Command { + Command::cargo_bin("clarion").expect("clarion binary") +} + +#[test] +fn analyze_without_plugins_writes_skipped_run_row() { + let dir = tempfile::tempdir().unwrap(); + + // Scrub PATH — if the developer or CI image has any clarion-plugin-* + // binary installed (including the project's own fixture), discovery + // will find it and the run transitions out of `skipped_no_plugins`. + // The sibling test `analyze_failrun_exits_nonzero_with_run_row_marked_failed` + // uses the same pattern. + clarion_bin() + .args(["install", "--path"]) + .arg(dir.path()) + .env("PATH", "") + .assert() + .success(); + + clarion_bin() + .args(["analyze"]) + .arg(dir.path()) + .env("PATH", "") + .assert() + .success(); + + let conn = Connection::open(dir.path().join(".clarion/clarion.db")).unwrap(); + let (count, status): (i64, String) = conn + .query_row( + "SELECT COUNT(*), COALESCE(MAX(status), '') FROM runs", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(count, 1); + assert_eq!(status, "skipped_no_plugins"); + + let entity_count: i64 = conn + .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0)) + .unwrap(); + assert_eq!(entity_count, 0); +} + +#[test] +fn analyze_fails_cleanly_if_clarion_dir_missing() { + let dir = tempfile::tempdir().unwrap(); + let out = clarion_bin() + .args(["analyze"]) + .arg(dir.path()) + .assert() + .failure(); + let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap(); + assert!( + stderr.contains("clarion install"), + "error did not point operator at install: {stderr}" + ); +} + +/// Regression for wp2 review-2 (clarion-f56dc6ee43): `FailRun` must exit +/// non-zero so `clarion analyze && next` chains and CI gating work. +/// +/// Triggers the discovery-errors `FailRun` branch by placing a +/// `clarion-plugin-*` executable on `$PATH` next to a malformed +/// `plugin.toml`. Before the fix, this exited 0; after, it exits non-zero +/// AND the `runs.status` column still reads `failed` (the run row is +/// marked before the bail). +#[cfg(unix)] +#[test] +fn analyze_failrun_exits_nonzero_with_run_row_marked_failed() { + use std::os::unix::fs::symlink; + + let project_dir = tempfile::tempdir().unwrap(); + let plugin_dir = tempfile::tempdir().unwrap(); + + clarion_bin() + .args(["install", "--path"]) + .arg(project_dir.path()) + .assert() + .success(); + + // Put a `clarion-plugin-broken` on the synthetic PATH alongside a + // malformed plugin.toml. Discovery will try to parse the toml and + // collect the error; with no compliant plugins, FailRun fires. + let plugin_bin = plugin_dir.path().join("clarion-plugin-broken"); + symlink("/bin/true", &plugin_bin).expect("symlink /bin/true"); + std::fs::write( + plugin_dir.path().join("plugin.toml"), + b"this is {not = valid toml @@@", + ) + .expect("write malformed plugin.toml"); + + let current_path = std::env::var_os("PATH").unwrap_or_default(); + let new_path = std::env::join_paths( + std::iter::once(plugin_dir.path().to_path_buf()) + .chain(std::env::split_paths(¤t_path)), + ) + .expect("join_paths"); + + let out = clarion_bin() + .args(["analyze"]) + .arg(project_dir.path()) + .env("PATH", &new_path) + .assert() + .failure(); + let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap(); + assert!( + stderr.contains("failed"), + "stderr should mention failure; got: {stderr}" + ); + + // The run row must still be marked `failed` — the FailRun WriterCmd + // runs before the bail, so the DB state is consistent with the exit + // code. + let conn = Connection::open(project_dir.path().join(".clarion/clarion.db")).unwrap(); + let status: String = conn + .query_row( + "SELECT status FROM runs ORDER BY started_at DESC LIMIT 1", + [], + |row| row.get(0), + ) + .expect("query latest run status"); + assert_eq!( + status, "failed", + "run row must be marked 'failed' to stay consistent with exit code" + ); +} diff --git a/crates/clarion-cli/tests/install.rs b/crates/clarion-cli/tests/install.rs new file mode 100644 index 00000000..646c452d --- /dev/null +++ b/crates/clarion-cli/tests/install.rs @@ -0,0 +1,134 @@ +//! `clarion install` integration tests. + +use std::fs; + +use assert_cmd::Command; +use rusqlite::Connection; + +fn clarion_bin() -> Command { + Command::cargo_bin("clarion").expect("clarion binary") +} + +#[test] +fn install_creates_clarion_dir_with_expected_contents() { + let dir = tempfile::tempdir().unwrap(); + clarion_bin() + .args(["install", "--path"]) + .arg(dir.path()) + .assert() + .success(); + + let clarion = dir.path().join(".clarion"); + assert!(clarion.join("clarion.db").exists(), "clarion.db missing"); + assert!(clarion.join("config.json").exists(), "config.json missing"); + assert!(clarion.join(".gitignore").exists(), ".gitignore missing"); + assert!( + dir.path().join("clarion.yaml").exists(), + "clarion.yaml not at project root" + ); + + let config = fs::read_to_string(clarion.join("config.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&config).unwrap(); + assert_eq!(parsed["schema_version"], 1); + assert!(parsed["last_run_id"].is_null()); + + let gitignore = fs::read_to_string(clarion.join(".gitignore")).unwrap(); + for rule in &[ + "*.shadow.db", + "tmp/", + "logs/", + "runs/*/log.jsonl", + "*-wal", + "*-shm", + ] { + assert!( + gitignore.contains(rule), + ".gitignore missing rule {rule}: {gitignore}" + ); + } +} + +#[test] +fn install_applies_migration_0001_exactly_once() { + let dir = tempfile::tempdir().unwrap(); + clarion_bin() + .args(["install", "--path"]) + .arg(dir.path()) + .assert() + .success(); + + let conn = Connection::open(dir.path().join(".clarion/clarion.db")).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 1); + let version: i64 = conn + .query_row("SELECT version FROM schema_migrations", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(version, 1); +} + +#[test] +fn install_refuses_to_overwrite_existing_clarion_dir() { + let dir = tempfile::tempdir().unwrap(); + clarion_bin() + .args(["install", "--path"]) + .arg(dir.path()) + .assert() + .success(); + + // Second install must fail with a clear message. + let out = clarion_bin() + .args(["install", "--path"]) + .arg(dir.path()) + .assert() + .failure(); + let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap(); + assert!( + stderr.contains("already exists"), + "error did not mention existing dir: {stderr}" + ); + assert!( + stderr.contains("--force"), + "error did not mention --force escape hatch: {stderr}" + ); +} + +#[test] +fn install_force_returns_unimplemented_in_sprint_one() { + let dir = tempfile::tempdir().unwrap(); + let out = clarion_bin() + .args(["install", "--force", "--path"]) + .arg(dir.path()) + .assert() + .failure(); + let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap(); + assert!( + stderr.contains("not implemented in Sprint 1"), + "expected Sprint 1 --force stub message: {stderr}" + ); +} + +#[test] +fn install_leaves_existing_clarion_yaml_untouched() { + let dir = tempfile::tempdir().unwrap(); + let yaml_path = dir.path().join("clarion.yaml"); + let user_content = "# user-edited clarion.yaml\nversion: 1\ncustom_key: preserved\n"; + fs::write(&yaml_path, user_content).unwrap(); + + clarion_bin() + .args(["install", "--path"]) + .arg(dir.path()) + .assert() + .success(); + + let after = fs::read_to_string(&yaml_path).unwrap(); + assert_eq!( + after, user_content, + "clarion.yaml was overwritten; user content lost" + ); +} diff --git a/crates/clarion-cli/tests/wp1_e2e.rs b/crates/clarion-cli/tests/wp1_e2e.rs new file mode 100644 index 00000000..5633e1f7 --- /dev/null +++ b/crates/clarion-cli/tests/wp1_e2e.rs @@ -0,0 +1,74 @@ +//! End-to-end WP1 smoke test — the minimum that must work at WP1 close. +//! +//! Mirrors docs/implementation/sprint-1/README.md §3 demo script for +//! Sprint 1 WP1 scope (no plugin, no entities — those land in WP2 + WP3). + +use assert_cmd::Command; +use rusqlite::Connection; + +fn clarion_bin() -> Command { + Command::cargo_bin("clarion").expect("clarion binary") +} + +#[test] +fn wp1_walking_skeleton_end_to_end() { + let dir = tempfile::tempdir().unwrap(); + + // Scrub PATH on every clarion invocation. The runner's PATH almost + // always contains world-writable directories (`/usr/local/bin`, + // `/opt/pipx_bin`, …) which trip WP2 scrub commit `7c0e396`'s + // refusal during plugin discovery; an empty PATH guarantees the + // `skipped_no_plugins` path this test asserts. Same pattern as + // `tests/analyze.rs::analyze_without_plugins_writes_skipped_run_row` + // (scrub commit `ad054bd`). + + // Step 1: clarion install + clarion_bin() + .args(["install", "--path"]) + .arg(dir.path()) + .env("PATH", "") + .assert() + .success(); + + let clarion_dir = dir.path().join(".clarion"); + assert!(clarion_dir.join("clarion.db").exists()); + assert!(clarion_dir.join("config.json").exists()); + assert!(clarion_dir.join(".gitignore").exists()); + assert!(dir.path().join("clarion.yaml").exists()); + + // Step 2: clarion analyze (no plugins yet — WP2 wires them) + clarion_bin() + .args(["analyze"]) + .arg(dir.path()) + .env("PATH", "") + .assert() + .success(); + + // Step 3: verify expected shape in the DB. + let conn = Connection::open(clarion_dir.join("clarion.db")).unwrap(); + + let migration_version: i64 = conn + .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(migration_version, 1, "schema not on migration 1"); + + let runs_count: i64 = conn + .query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0)) + .unwrap(); + assert_eq!(runs_count, 1, "expected exactly one run row"); + + let run_status: String = conn + .query_row("SELECT status FROM runs", [], |row| row.get(0)) + .unwrap(); + assert_eq!(run_status, "skipped_no_plugins"); + + let entity_count: i64 = conn + .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0)) + .unwrap(); + assert_eq!(entity_count, 0); + + // WP2+WP3 extend this test to assert a non-zero entity count with the + // expected 3-segment ID (L2 format `python:function:demo.hello`). +} diff --git a/crates/clarion-cli/tests/wp2_e2e.rs b/crates/clarion-cli/tests/wp2_e2e.rs new file mode 100644 index 00000000..cbc13b46 --- /dev/null +++ b/crates/clarion-cli/tests/wp2_e2e.rs @@ -0,0 +1,494 @@ +//! WP2 Task 9 — end-to-end smoke test. +//! +//! Proves signoff A.2.8: the full Sprint 1 walking-skeleton pipeline works. +//! +//! Scenario: +//! 1. `clarion install` initialises `.clarion/clarion.db`. +//! 2. A `clarion-plugin-fixture` binary is placed on a synthetic `$PATH` +//! alongside its `plugin.toml` (neighbour-discovery convention, L9). +//! 3. A single source file `demo.mt` is created in the project root. +//! 4. `clarion analyze` discovers the fixture plugin, spawns it, +//! handshakes, calls `analyze_file` once, receives one entity, and +//! persists it to the `entities` table. +//! +//! Asserts the full round-trip preserves entity identity: the persisted +//! row exactly matches the fixture plugin's declared emission +//! (`fixture:widget:demo.sample`). + +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::{env, fs}; + +use assert_cmd::Command; +use rusqlite::Connection; +use tempfile::TempDir; + +fn clarion_bin() -> Command { + Command::cargo_bin("clarion").expect("clarion binary") +} + +/// Locate the `clarion-plugin-fixture` binary. +/// +/// Tries `CARGO_BIN_EXE_clarion-plugin-fixture` first (set by cargo nextest +/// when `clarion-plugin-fixture` appears in `[dev-dependencies]`). Falls back +/// to the standard `target/{debug,release}/` search. +fn fixture_binary_path() -> PathBuf { + if let Ok(path) = env::var("CARGO_BIN_EXE_clarion-plugin-fixture") { + return PathBuf::from(path); + } + + // Fallback: search target/ relative to the workspace root. + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // clarion-cli is at crates/clarion-cli; workspace root is ../../ + let workspace_root = manifest_dir + .parent() // crates/ + .and_then(|p| p.parent()) // workspace root + .expect("workspace root must exist"); + + let target_dir = + env::var("CARGO_TARGET_DIR").map_or_else(|_| workspace_root.join("target"), PathBuf::from); + + for profile in &["debug", "release"] { + let candidate = target_dir.join(profile).join("clarion-plugin-fixture"); + if candidate.exists() { + return candidate; + } + } + + panic!( + "clarion-plugin-fixture binary not found. \ + Run `cargo build --workspace` before running this test. \ + Searched: {}", + target_dir.display() + ); +} + +/// Set up a synthetic `$PATH` directory containing: +/// - `clarion-plugin-fixture` executable (symlink to the real binary). +/// - `plugin.toml` manifest (copied from the core test fixtures). +/// +/// Returns the temp dir (must stay alive for the duration of the test). +fn setup_plugin_dir(fixture_bin: &PathBuf) -> TempDir { + let plugin_dir = TempDir::new().expect("create plugin tempdir"); + + // Symlink the fixture binary into the dir under its expected name. + let dest = plugin_dir.path().join("clarion-plugin-fixture"); + std::os::unix::fs::symlink(fixture_bin, &dest).expect("symlink clarion-plugin-fixture"); + + // Verify the target is executable. + let meta = fs::metadata(fixture_bin).expect("stat fixture binary"); + assert!( + meta.permissions().mode() & 0o111 != 0, + "fixture binary must be executable" + ); + + // Copy the `plugin.toml` fixture next to the binary (neighbor convention). + let toml_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() // crates/ + .unwrap() + .join("clarion-core") + .join("tests") + .join("fixtures") + .join("plugin.toml"); + let toml_dest = plugin_dir.path().join("plugin.toml"); + fs::copy(&toml_src, &toml_dest).expect("copy plugin.toml"); + + plugin_dir +} + +#[test] +fn wp2_e2e_smoke_fixture_plugin_round_trip() { + // 1. Locate the fixture binary. + let fixture_bin = fixture_binary_path(); + + // 2. Create a synthetic $PATH directory with the plugin and manifest. + let plugin_dir = setup_plugin_dir(&fixture_bin); + + // 3. Set up the project directory. + let project_dir = TempDir::new().expect("create project tempdir"); + + // 4. `clarion install` to initialise `.clarion/`. + clarion_bin() + .args(["install", "--path"]) + .arg(project_dir.path()) + .assert() + .success(); + + // 5. Place a source file the fixture plugin claims (`*.mt`). + fs::write( + project_dir.path().join("demo.mt"), + b"widget demo.sample {}\n", + ) + .expect("write demo.mt"); + + // 6. Build a synthetic PATH from the plugin dir alone. We do NOT inherit + // the runner's PATH — CI runners (and many dev workstations) have + // world-writable directories like `/usr/local/bin` and `/opt/pipx_bin` + // that trip WP2's discovery refusal (scrub commit `7c0e396`). The test + // doesn't need anything from the inherited PATH. + let new_path = + env::join_paths(std::iter::once(plugin_dir.path().to_path_buf())).expect("join_paths"); + + // 7. Run `clarion analyze` with the synthetic PATH. + clarion_bin() + .args(["analyze"]) + .arg(project_dir.path()) + .env("PATH", &new_path) + .assert() + .success(); + + // 8. Verify the database — full round-trip identity assertions. + let db_path = project_dir.path().join(".clarion/clarion.db"); + let conn = Connection::open(&db_path).expect("open db"); + + // Assert 1 + 2: exactly one run row with status "completed". + let (run_count, run_status): (i64, String) = conn + .query_row( + "SELECT COUNT(*), COALESCE(MAX(status), '') FROM runs", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("query runs"); + + assert_eq!( + run_count, 1, + "expected exactly one run row; got {run_count}" + ); + assert_eq!( + run_status, "completed", + "run status must be 'completed'; got {run_status:?}" + ); + + // Assert 3: stats JSON reports entities_inserted = 1. + let stats_raw: String = conn + .query_row("SELECT stats FROM runs LIMIT 1", [], |row| row.get(0)) + .expect("query runs.stats"); + let stats: serde_json::Value = + serde_json::from_str(&stats_raw).expect("stats column must be valid JSON"); + assert_eq!( + stats["entities_inserted"], + serde_json::Value::Number(1.into()), + "stats must report entities_inserted = 1; got {stats_raw:?}" + ); + + // Assert 4: exactly one entity row. + let entity_count: i64 = conn + .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0)) + .expect("query entities count"); + + assert_eq!( + entity_count, 1, + "expected exactly one entity row; got {entity_count}" + ); + + // Asserts 5–8: the persisted row matches the fixture's declared emission. + let (entity_id, entity_kind, entity_plugin_id, entity_name): (String, String, String, String) = + conn.query_row( + "SELECT id, kind, plugin_id, name FROM entities LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .expect("query entity row"); + + assert_eq!( + entity_id, "fixture:widget:demo.sample", + "entity id must be 'fixture:widget:demo.sample'; got {entity_id:?}" + ); + assert_eq!( + entity_kind, "widget", + "entity kind must be 'widget'; got {entity_kind:?}" + ); + assert_eq!( + entity_plugin_id, "fixture", + "entity plugin_id must be 'fixture'; got {entity_plugin_id:?}" + ); + assert_eq!( + entity_name, "demo.sample", + "entity name must be 'demo.sample'; got {entity_name:?}" + ); +} + +/// Regression for wp2 review-2 (clarion-978c8d6f15): crash-loop breaker is +/// wired into the production analyze path AND a single plugin crash no +/// longer tanks the whole run. +/// +/// Scenario: +/// - `clarion-plugin-fixture` + its manifest in `plugin_dir_a` (extensions = mt) +/// - `clarion-plugin-broken` (symlink to /bin/true) + a manifest declaring +/// `plugin_id` "broken" and extensions = "bk" in `plugin_dir_b` +/// - Project root has `demo.mt` (fixture input) and `demo.bk` (broken input) +/// - Both plugin dirs prepended to PATH +/// +/// Expected: `broken` fails handshake (no response on closed stdout), its +/// crash is recorded on the breaker, the run continues, and the fixture +/// plugin processes `demo.mt` successfully. The run resolves to `failed` +/// (exit 1, runs.status = 'failed') but the fixture's entity is persisted +/// — continue-past-crash preserves partial work. +#[test] +fn wp2_crash_in_one_plugin_does_not_prevent_other_plugins_from_running() { + // 1. Locate the fixture binary. + let fixture_bin = fixture_binary_path(); + + // 2. plugin_dir_a: working fixture. + let plugin_dir_a = setup_plugin_dir(&fixture_bin); + + // 3. plugin_dir_b: broken plugin pointing at /bin/true. + let plugin_dir_b = TempDir::new().expect("create broken plugin dir"); + let broken_bin = plugin_dir_b.path().join("clarion-plugin-broken"); + std::os::unix::fs::symlink("/bin/true", &broken_bin).expect("symlink /bin/true"); + let broken_manifest = r#" +[plugin] +name = "clarion-plugin-broken" +plugin_id = "broken" +version = "0.1.0" +protocol_version = "1.0" +executable = "clarion-plugin-broken" +language = "broken" +extensions = ["bk"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-BROKEN-" +ontology_version = "0.1.0" +"#; + fs::write(plugin_dir_b.path().join("plugin.toml"), broken_manifest) + .expect("write broken plugin.toml"); + + // 4. Set up project directory with one file per plugin extension. + let project_dir = TempDir::new().expect("create project tempdir"); + clarion_bin() + .args(["install", "--path"]) + .arg(project_dir.path()) + .assert() + .success(); + fs::write( + project_dir.path().join("demo.mt"), + b"widget demo.sample {}\n", + ) + .expect("write demo.mt"); + fs::write(project_dir.path().join("demo.bk"), b"// broken's input\n").expect("write demo.bk"); + + // 5. PATH with BOTH plugin dirs only — no inheritance of the runner's + // PATH (see the rationale at the first synthetic-PATH construction + // above: world-writable runner dirs trip WP2's discovery refusal). + let new_path = env::join_paths([ + plugin_dir_a.path().to_path_buf(), + plugin_dir_b.path().to_path_buf(), + ]) + .expect("join_paths"); + + // 6. analyze must exit non-zero (a plugin crashed) but the run still + // processes the other plugin's files. + let out = clarion_bin() + .args(["analyze"]) + .arg(project_dir.path()) + .env("PATH", &new_path) + .assert() + .failure(); + let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap(); + assert!( + stderr.contains("broken"), + "stderr should name the crashed plugin; got: {stderr}" + ); + + // 7. Verify the DB: run = 'failed', entity from fixture IS persisted. + // `fail_run` writes the reason into stats.failure_reason (JSON). + let conn = Connection::open(project_dir.path().join(".clarion/clarion.db")).unwrap(); + let (row_count, run_status, stats_raw): (i64, String, String) = conn + .query_row( + "SELECT COUNT(*), COALESCE(MAX(status), ''), COALESCE(MAX(stats), '') FROM runs", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("query runs"); + assert_eq!(row_count, 1, "expected exactly one run row"); + assert_eq!( + run_status, "failed", + "any-plugin-crash must still mark run as failed; got {run_status:?}" + ); + let stats: serde_json::Value = + serde_json::from_str(&stats_raw).expect("stats must be valid JSON"); + let failure_reason = stats["failure_reason"] + .as_str() + .expect("failure_reason must be a string"); + assert!( + failure_reason.contains("broken"), + "failure_reason should name the crashed plugin; got {failure_reason:?}" + ); + + // This is the behavioural assertion that matters: the fixture plugin's + // entity is persisted even though `broken` crashed earlier in the run. + let entity_count: i64 = conn + .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0)) + .expect("query entities count"); + assert_eq!( + entity_count, 1, + "fixture plugin's entity must still be persisted despite broken plugin's crash; got {entity_count}", + ); + let entity_plugin_id: String = conn + .query_row("SELECT plugin_id FROM entities LIMIT 1", [], |row| { + row.get(0) + }) + .expect("query entity plugin_id"); + assert_eq!( + entity_plugin_id, "fixture", + "surviving entity must be from the fixture plugin; got {entity_plugin_id:?}" + ); +} + +// ── E2E: crash-loop breaker trip skips remaining plugins ───────────────────── +// +// A.2.7 signoff requires proof that the crash-loop breaker trips after the +// configured number of crashes. `breaker_06` exercises the breaker against +// `MockPlugin::new_crashing` directly, without touching the `analyze.rs` +// wiring added in commit a1cc3be. This test drives four broken plugins and +// a fixture through the CLI end-to-end: the breaker must trip on the fourth +// crash (threshold `>3`, per ADR-002 + UQ-WP2-10), emit +// FINDING_DISABLED_CRASH_LOOP, and skip the fixture plugin that would +// otherwise have succeeded. +// +// Regression for clarion-581bcfa0e5. +#[test] +fn wp2_crash_loop_breaker_trips_and_skips_remaining_plugins() { + // 1. Build four broken plugin dirs, each symlinking to /bin/true. + // `/bin/true` succeeds immediately without reading stdin, so the + // handshake read returns EOF → transport error → plugin crash. + // Each has a unique plugin_id, extension, and rule_id_prefix so the + // manifests parse as distinct plugins. + let mut broken_dirs: Vec = Vec::new(); + for i in 0..4u8 { + let dir = TempDir::new().expect("create broken plugin dir"); + let suffix = format!("broken{i}"); + let binary = dir.path().join(format!("clarion-plugin-{suffix}")); + std::os::unix::fs::symlink("/bin/true", &binary).expect("symlink /bin/true"); + + let manifest = format!( + r#"[plugin] +name = "clarion-plugin-{suffix}" +plugin_id = "{suffix}" +version = "0.1.0" +protocol_version = "1.0" +executable = "clarion-plugin-{suffix}" +language = "{suffix}" +extensions = ["b{i}"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-{PREFIX}-" +ontology_version = "0.1.0" +"#, + PREFIX = suffix.to_uppercase(), + ); + fs::write(dir.path().join("plugin.toml"), manifest).expect("write broken manifest"); + broken_dirs.push(dir); + } + + // 2. Fixture plugin dir — placed LAST in PATH so discovery yields it + // AFTER the four broken plugins. Once the breaker trips on the + // fourth crash, the analyze loop `break`s and the fixture plugin + // must not run. + let fixture_bin = fixture_binary_path(); + let fixture_dir = setup_plugin_dir(&fixture_bin); + + // 3. Project with one matching file per plugin extension. The fixture + // input MUST be present — its absence would skip the fixture plugin + // via the "no files match" path at analyze.rs ~208, confounding the + // "skipped by breaker" assertion. + let project_dir = TempDir::new().expect("create project tempdir"); + clarion_bin() + .args(["install", "--path"]) + .arg(project_dir.path()) + .assert() + .success(); + for i in 0..4u8 { + fs::write(project_dir.path().join(format!("demo.b{i}")), b"x\n") + .expect("write broken input"); + } + fs::write( + project_dir.path().join("demo.mt"), + b"widget demo.sample {}\n", + ) + .expect("write fixture input"); + + // 4. PATH — broken dirs first (in order), fixture last. Discovery + // iterates PATH entries in order (see discover_on_path); within a + // directory the plugin name is distinct per dir, so no shadowing. + let mut parts: Vec = broken_dirs.iter().map(|d| d.path().to_path_buf()).collect(); + parts.push(fixture_dir.path().to_path_buf()); + let new_path = env::join_paths(parts).expect("join_paths"); + + // 5. analyze must fail (exit 1) — plugins crashed. + let out = clarion_bin() + .args(["analyze"]) + .arg(project_dir.path()) + .env("PATH", &new_path) + .assert() + .failure(); + // `tracing_subscriber::fmt::init()` writes events to STDOUT (not + // stderr). `anyhow::Error` propagated by `main()` via `?` is what hits + // stderr. So we check stdout for the tracing log line and stderr for + // the process-level error. + let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap(); + let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap(); + + // 6. The breaker-tripped tracing line must appear in stdout. This + // proves analyze.rs:240 reached CrashLoopState::Tripped — the + // wiring that breaker_06's mock-only test does not cover. + assert!( + stdout.contains("crash-loop breaker tripped"), + "breaker-tripped log line missing from stdout.\nstdout: {stdout}\nstderr: {stderr}" + ); + assert!( + stdout.contains("CLA-INFRA-PLUGIN-DISABLED-CRASH-LOOP"), + "FINDING_DISABLED_CRASH_LOOP subcode missing from stdout.\nstdout: {stdout}\nstderr: {stderr}" + ); + + // 7. Fixture plugin must NOT have produced an entity — the break + // statement at analyze.rs ~247 must have fired before it ran. + let conn = Connection::open(project_dir.path().join(".clarion/clarion.db")).unwrap(); + let entity_count: i64 = conn + .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0)) + .expect("query entities count"); + assert_eq!( + entity_count, 0, + "fixture plugin must be skipped after breaker trips; got {entity_count} entities" + ); + + // 8. Run row must be marked failed with a reason naming the crashed + // plugins. + let (run_status, run_stats_json): (String, String) = conn + .query_row("SELECT status, stats FROM runs LIMIT 1", [], |row| { + Ok((row.get(0)?, row.get(1)?)) + }) + .expect("query runs"); + assert_eq!(run_status, "failed"); + let parsed_stats: serde_json::Value = + serde_json::from_str(&run_stats_json).expect("stats JSON"); + let failure_reason = parsed_stats["failure_reason"] + .as_str() + .expect("failure_reason string"); + // At least 4 plugins crashed (the fourth triggered the trip, so the + // breaker-tripped branch `break`s out of the loop before a fifth could + // record). + let crash_plugin_mentions = (0..4u8) + .filter(|i| failure_reason.contains(&format!("broken{i}"))) + .count(); + assert_eq!( + crash_plugin_mentions, 4, + "failure_reason must name all 4 crashed plugins; got {failure_reason:?}", + ); +} diff --git a/crates/clarion-core/Cargo.toml b/crates/clarion-core/Cargo.toml new file mode 100644 index 00000000..ab0e429c --- /dev/null +++ b/crates/clarion-core/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "clarion-core" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +toml.workspace = true +tracing.workspace = true +nix = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/clarion-core/src/entity_id.rs b/crates/clarion-core/src/entity_id.rs new file mode 100644 index 00000000..864feb89 --- /dev/null +++ b/crates/clarion-core/src/entity_id.rs @@ -0,0 +1,367 @@ +//! Entity-ID assembler. +//! +//! Per ADR-003 + ADR-022, every Clarion entity has a stable 3-segment ID: +//! `{plugin_id}:{kind}:{canonical_qualified_name}`. +//! +//! - `plugin_id` and `kind` must match the grammar `[a-z][a-z0-9_]*`. +//! - `canonical_qualified_name` is opaque to this assembler: its internal +//! shape is the emitting plugin's concern (dotted qualnames for the +//! Python plugin; content-addressed for core-minted file entities). +//! - No segment may contain a literal `:` — the separator is reserved. +//! ADR-022's grammar precludes it in `plugin_id`/`kind`; `canonical_qualified_name` +//! is checked at assembly time (UQ-WP1-07). + +use std::fmt; + +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct EntityId(String); + +impl EntityId { + /// Returns the entity ID as a string slice in its canonical 3-segment form. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for EntityId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::str::FromStr for EntityId { + type Err = EntityIdError; + + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.splitn(3, ':').collect(); + match parts.as_slice() { + [plugin_id, kind, canonical_qualified_name] => { + entity_id(plugin_id, kind, canonical_qualified_name) + } + _ => Err(EntityIdError::MalformedId { + value: s.to_owned(), + }), + } + } +} + +impl<'de> serde::Deserialize<'de> for EntityId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + let s = String::deserialize(deserializer)?; + s.parse::().map_err(D::Error::custom) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum EntityIdError { + #[error("segment {field} empty")] + EmptySegment { field: &'static str }, + + #[error("segment {field} violates ADR-022 grammar [a-z][a-z0-9_]*: {value:?}")] + GrammarViolation { field: &'static str, value: String }, + + #[error("segment {field} contains reserved ':' separator: {value:?}")] + SegmentContainsColon { field: &'static str, value: String }, + + #[error("EntityId must have exactly 3 colon-separated segments, got: {value:?}")] + MalformedId { value: String }, +} + +/// Assemble an [`EntityId`] from its three segments. +/// +/// `plugin_id` and `kind` are validated against the ADR-022 grammar +/// (`[a-z][a-z0-9_]*`). `canonical_qualified_name` is opaque but may not +/// contain `:`. +/// +/// # Errors +/// +/// - [`EntityIdError::EmptySegment`] if any segment is empty. +/// - [`EntityIdError::GrammarViolation`] if `plugin_id` or `kind` does not +/// match the ADR-022 grammar. +/// - [`EntityIdError::SegmentContainsColon`] if any segment contains `:` +/// (colon is reserved as the segment separator; UQ-WP1-07). +pub fn entity_id( + plugin_id: &str, + kind: &str, + canonical_qualified_name: &str, +) -> Result { + validate_grammar("plugin_id", plugin_id)?; + validate_grammar("kind", kind)?; + if canonical_qualified_name.is_empty() { + return Err(EntityIdError::EmptySegment { + field: "canonical_qualified_name", + }); + } + validate_no_colon("canonical_qualified_name", canonical_qualified_name)?; + Ok(EntityId(format!( + "{plugin_id}:{kind}:{canonical_qualified_name}" + ))) +} + +/// Validate that a string matches the ADR-022 identifier grammar `[a-z][a-z0-9_]*`. +/// +/// Used by both the entity-ID assembler and the manifest parser to enforce a +/// single canonical check — no divergent copies. +pub(crate) fn validate_kind_grammar(value: &str) -> bool { + if value.is_empty() { + return false; + } + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !first.is_ascii_lowercase() { + return false; + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +fn validate_grammar(field: &'static str, value: &str) -> Result<(), EntityIdError> { + if value.is_empty() { + return Err(EntityIdError::EmptySegment { field }); + } + validate_no_colon(field, value)?; + let mut chars = value.chars(); + let Some(first) = chars.next() else { + // Unreachable: emptiness is checked above, but the defensive branch + // avoids any panic path and satisfies clippy::unwrap_in_result. + return Err(EntityIdError::EmptySegment { field }); + }; + if !first.is_ascii_lowercase() { + return Err(EntityIdError::GrammarViolation { + field, + value: value.to_owned(), + }); + } + for c in chars { + if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') { + return Err(EntityIdError::GrammarViolation { + field, + value: value.to_owned(), + }); + } + } + Ok(()) +} + +fn validate_no_colon(field: &'static str, value: &str) -> Result<(), EntityIdError> { + if value.contains(':') { + return Err(EntityIdError::SegmentContainsColon { + field, + value: value.to_owned(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, serde::Deserialize)] + struct FixtureRow { + plugin_id: String, + kind: String, + canonical_qualified_name: String, + expected_entity_id: String, + } + + #[test] + fn module_level_function() { + let id = entity_id("python", "function", "demo.hello").unwrap(); + assert_eq!(id.as_str(), "python:function:demo.hello"); + } + + #[test] + fn class_method() { + let id = entity_id("python", "function", "demo.Foo.bar").unwrap(); + assert_eq!(id.as_str(), "python:function:demo.Foo.bar"); + } + + #[test] + fn nested_function_uses_python_locals_marker() { + let id = entity_id("python", "function", "demo.outer..inner").unwrap(); + assert_eq!(id.as_str(), "python:function:demo.outer..inner"); + } + + #[test] + fn core_reserved_file_kind() { + // The file-entity canonical_qualified_name shape is core-file-discovery's + // concern (per detailed-design.md §2:229). Sprint 1 only tests the + // assembler's concatenation; `src/demo.py` is a stand-in. + let id = entity_id("core", "file", "src/demo.py").unwrap(); + assert_eq!(id.as_str(), "core:file:src/demo.py"); + } + + #[test] + fn core_reserved_subsystem_kind() { + let id = entity_id("core", "subsystem", "a1b2c3d4").unwrap(); + assert_eq!(id.as_str(), "core:subsystem:a1b2c3d4"); + } + + #[test] + fn rejects_empty_plugin_id() { + assert_eq!( + entity_id("", "function", "demo.hello"), + Err(EntityIdError::EmptySegment { field: "plugin_id" }), + ); + } + + #[test] + fn rejects_empty_kind() { + assert_eq!( + entity_id("python", "", "demo.hello"), + Err(EntityIdError::EmptySegment { field: "kind" }), + ); + } + + #[test] + fn rejects_empty_qualified_name() { + assert_eq!( + entity_id("python", "function", ""), + Err(EntityIdError::EmptySegment { + field: "canonical_qualified_name", + }), + ); + } + + #[test] + fn rejects_uppercase_plugin_id() { + assert!(matches!( + entity_id("Python", "function", "demo.hello"), + Err(EntityIdError::GrammarViolation { + field: "plugin_id", + .. + }) + )); + } + + #[test] + fn rejects_digit_prefixed_kind() { + assert!(matches!( + entity_id("python", "1function", "demo.hello"), + Err(EntityIdError::GrammarViolation { field: "kind", .. }) + )); + } + + #[test] + fn rejects_hyphen_in_kind() { + assert!(matches!( + entity_id("python", "func-tion", "demo.hello"), + Err(EntityIdError::GrammarViolation { field: "kind", .. }) + )); + } + + #[test] + fn rejects_colon_in_qualified_name() { + assert!(matches!( + entity_id("python", "function", "demo:hello"), + Err(EntityIdError::SegmentContainsColon { + field: "canonical_qualified_name", + .. + }) + )); + } + + #[test] + fn rejects_colon_in_plugin_id() { + // Defence in depth: grammar check rejects this, but the colon + // check fires first and produces a more descriptive error. + let err = entity_id("py:thon", "function", "demo.hello").unwrap_err(); + assert!(matches!( + err, + EntityIdError::SegmentContainsColon { + field: "plugin_id", + .. + } + )); + } + + #[test] + fn entity_id_serialises_as_string() { + let id = entity_id("python", "function", "demo.hello").unwrap(); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"python:function:demo.hello\""); + } + + #[test] + fn parse_roundtrip_via_from_str() { + use std::str::FromStr; + let id = entity_id("python", "function", "demo.hello").unwrap(); + let parsed = EntityId::from_str(id.as_str()).unwrap(); + assert_eq!(parsed, id); + } + + #[test] + fn from_str_rejects_fewer_than_three_segments() { + use std::str::FromStr; + let err = EntityId::from_str("python:function").unwrap_err(); + assert!(matches!(err, EntityIdError::MalformedId { .. })); + } + + #[test] + fn from_str_rejects_empty_segments_via_underlying_validator() { + use std::str::FromStr; + // splitn(3, ':') on "::foo" yields ["", "", "foo"] — empty plugin_id + let err = EntityId::from_str("::demo.hello").unwrap_err(); + assert!(matches!( + err, + EntityIdError::EmptySegment { field: "plugin_id" } + )); + } + + #[test] + fn deserialize_validates_through_from_str() { + // Valid input round-trips. + let id: EntityId = serde_json::from_str("\"python:function:demo.hello\"").unwrap(); + assert_eq!(id.as_str(), "python:function:demo.hello"); + } + + #[test] + fn deserialize_rejects_invalid_ids() { + // An unstructured string must fail deserialisation now (pre-fix, it + // would silently deserialise into a corrupt EntityId). + let result: Result = serde_json::from_str("\"notanid\""); + assert!( + result.is_err(), + "expected custom deserialiser to reject non-3-segment input" + ); + } + + #[test] + fn shared_fixture_byte_for_byte_parity() { + // L2 byte-for-byte parity proof (WP3 Task 5 / UQ-WP3-08): this + // test and `plugins/python/tests/test_entity_id.py::test_matches_shared_fixture` + // consume the same `fixtures/entity_id.json` at the workspace root. + // Divergence on either side fails CI. Retroactively earns the + // signoff A.1.4 proof (WP1 ticked it against the fixture before + // the file existed — WP3 Task 5 is where it lands). + let fixture_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/entity_id.json"); + let contents = std::fs::read_to_string(&fixture_path) + .unwrap_or_else(|err| panic!("read fixture {}: {err}", fixture_path.display())); + let rows: Vec = + serde_json::from_str(&contents).expect("fixture parses as Vec"); + assert!( + rows.len() >= 20, + "fixture must have at least 20 rows; got {}", + rows.len() + ); + for row in &rows { + let actual = entity_id(&row.plugin_id, &row.kind, &row.canonical_qualified_name) + .unwrap_or_else(|err| panic!("row {row:?} failed to assemble: {err}")); + assert_eq!( + actual.as_str(), + row.expected_entity_id, + "mismatch on row {row:?}" + ); + } + } +} diff --git a/crates/clarion-core/src/lib.rs b/crates/clarion-core/src/lib.rs new file mode 100644 index 00000000..22861987 --- /dev/null +++ b/crates/clarion-core/src/lib.rs @@ -0,0 +1,36 @@ +//! clarion-core — domain types, identifiers, and provider traits. +//! +//! # Re-export policy (ticket clarion-29acbcd042) +//! +//! Only facade types that external callers need are re-exported at the crate +//! root. Implementation types (`Frame`, `TransportError`, `RequestEnvelope`, etc.) +//! remain accessible via `clarion_core::plugin::transport::*` and siblings. + +pub mod entity_id; +pub mod llm_provider; +pub mod plugin; + +pub use entity_id::{EntityId, EntityIdError, entity_id}; +pub use llm_provider::{LlmProvider, NoopProvider}; +pub use plugin::{ + // host (Task 6) — facade for callers that spawn/connect plugins + AcceptedEntity, + CapExceeded, + // breaker (Task 7) — callers drive crash-loop accounting + CrashLoopBreaker, + CrashLoopState, + // discovery (Task 5) — callers enumerate plugins + DiscoveredPlugin, + DiscoveryError, + FINDING_DISABLED_CRASH_LOOP, + HostError, + HostFinding, + // jail / limits errors — callers may want to match on these + JailError, + // manifest (Task 1) — callers parse manifests from disk + Manifest, + ManifestError, + PluginHost, + discover, + parse_manifest, +}; diff --git a/crates/clarion-core/src/llm_provider.rs b/crates/clarion-core/src/llm_provider.rs new file mode 100644 index 00000000..d7f3b2de --- /dev/null +++ b/crates/clarion-core/src/llm_provider.rs @@ -0,0 +1,48 @@ +//! `LlmProvider` trait stub. +//! +//! WP6 (summary-cache + prompt dispatch) fills this out. Sprint 1 defines +//! the hook point so the trait has a stable import path from day one. +//! `NoopProvider` panics if its `name()` is called — Sprint 1 has no +//! code path that legitimately calls it, so panic is a louder bug signal +//! than a silent default. + +pub trait LlmProvider: Send + Sync { + /// Human-readable provider identifier. + fn name(&self) -> &str; +} + +/// Stub provider used in Sprint 1 code paths that take a provider +/// argument. Calling `name()` panics — if you see this panic, something +/// in the WP1 code is reaching for a real provider before WP6 lands. +pub struct NoopProvider; + +impl LlmProvider for NoopProvider { + /// Always panics. + /// + /// # Panics + /// + /// `NoopProvider` is a Sprint-1 stub; any call to `name()` indicates + /// `WP1` code is reaching for a real provider before `WP6` lands. + fn name(&self) -> &str { + panic!("NoopProvider::name called — WP6 should have replaced this by now") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noop_provider_implements_trait() { + fn assert_trait(_: &T) {} + let p = NoopProvider; + assert_trait(&p); + } + + #[test] + #[should_panic(expected = "NoopProvider::name called")] + fn noop_provider_panics_on_name() { + let p = NoopProvider; + let _ = p.name(); + } +} diff --git a/crates/clarion-core/src/plugin/breaker.rs b/crates/clarion-core/src/plugin/breaker.rs new file mode 100644 index 00000000..b9ca1a74 --- /dev/null +++ b/crates/clarion-core/src/plugin/breaker.rs @@ -0,0 +1,360 @@ +//! Crash-loop breaker for plugin spawn attempts. +//! +//! Implements ADR-002 (crash-loop breaker) and UQ-WP2-10 (threshold = >3 +//! crashes in 60 s). When the breaker trips, the host refuses further +//! spawn attempts for the rolling-window duration. +//! +//! Sprint 1 hard-codes the threshold and window per UQ-WP2-10; the config +//! surface (`clarion.yaml:plugin_limits.crash_*`) lands in WP6. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +// ── Finding subcode constants ───────────────────────────────────────────────── + +/// Subcode emitted when the breaker trips. +pub const FINDING_DISABLED_CRASH_LOOP: &str = "CLA-INFRA-PLUGIN-DISABLED-CRASH-LOOP"; + +// ── CrashLoopState ──────────────────────────────────────────────────────────── + +/// State returned by [`CrashLoopBreaker::record_crash`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CrashLoopState { + /// Within the threshold; further spawn attempts allowed. + Open, + /// Threshold exceeded; further spawn attempts should be refused. + Tripped, +} + +// ── CrashLoopBreaker ────────────────────────────────────────────────────────── + +/// Rolling-window crash counter. +/// +/// Trips when **more than 3** plugin crashes occur within a 60-second window +/// per ADR-002 + UQ-WP2-10. The `>3` threshold (not `>=3`) is specified by +/// UQ-WP2-10. +/// +/// # Clock injection +/// +/// The public API uses [`Instant::now()`] internally. Tests use the +/// crate-internal `record_crash_at` helper to inject arbitrary instants +/// without sleeping. +pub struct CrashLoopBreaker { + /// Rolling window length — default 60 s per ADR-002 + UQ-WP2-10. + window: Duration, + /// Breaker trips when `events.len() > threshold` after pruning. + threshold: usize, + /// Timestamps of recent crash events within the window. + events: VecDeque, +} + +impl CrashLoopBreaker { + /// 60 s rolling window per ADR-002 + UQ-WP2-10. + pub const DEFAULT_WINDOW: Duration = Duration::from_secs(60); + /// >3 crashes per window trips per UQ-WP2-10. + pub const DEFAULT_THRESHOLD: usize = 3; + + /// Construct a breaker with explicit window and threshold. + pub fn new(window: Duration, threshold: usize) -> Self { + Self { + window, + threshold, + events: VecDeque::new(), + } + } + + /// Record a crash at `Instant::now()` and return the resulting state. + pub fn record_crash(&mut self) -> CrashLoopState { + self.record_crash_at(Instant::now()) + } + + /// Current state without recording a new crash (useful pre-spawn check). + /// + /// Counts events within the rolling window and compares to the threshold. + /// Does NOT eagerly prune stale events from the underlying storage; call + /// [`record_crash`](Self::record_crash) (or `record_crash_at`) to trigger + /// pruning. In practice the `VecDeque` length is bounded by crash rate × + /// window, so unbounded-growth-without-record_crash is not a realistic + /// production scenario. + pub fn state(&self) -> CrashLoopState { + let now = Instant::now(); + let live_count = self + .events + .iter() + .filter(|&&t| { + now.checked_duration_since(t) + .is_none_or(|age| age < self.window) + }) + .count(); + if live_count > self.threshold { + CrashLoopState::Tripped + } else { + CrashLoopState::Open + } + } + + /// Test hook — accepts an injected `Instant` to make rolling-window + /// pruning deterministic under test. Crate-internal. + pub(crate) fn record_crash_at(&mut self, at: Instant) -> CrashLoopState { + self.events.push_back(at); + // Prune events outside the rolling window relative to `at`. + self.events.retain(|&t| { + // Keep events where `at - t < window`, i.e., `t > at - window`. + // `at.checked_duration_since(t)` is `None` if `t > at` (future + // instant, possible with injected clocks) — treat as "just + // happened" (keep). + at.checked_duration_since(t) + .is_none_or(|age| age < self.window) + }); + + if self.events.len() > self.threshold { + CrashLoopState::Tripped + } else { + CrashLoopState::Open + } + } +} + +impl Default for CrashLoopBreaker { + fn default() -> Self { + Self::new(Self::DEFAULT_WINDOW, Self::DEFAULT_THRESHOLD) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── Unit tests (pure breaker, no real sleep) ────────────────────────────── + + /// `breaker_01`: new breaker returns Open from `state()`. + #[test] + fn breaker_01_zero_crashes_is_open() { + let b = CrashLoopBreaker::default(); + assert_eq!( + b.state(), + CrashLoopState::Open, + "fresh breaker must be Open" + ); + } + + /// `breaker_02`: record 3 crashes at a single Instant; `state()` returns Open. + /// Threshold is >3, so exactly 3 crashes does not trip it. + #[test] + fn breaker_02_at_threshold_stays_open() { + let mut b = CrashLoopBreaker::default(); + let t = Instant::now(); + let mut state = CrashLoopState::Open; + for _ in 0..3 { + state = b.record_crash_at(t); + } + assert_eq!( + state, + CrashLoopState::Open, + "3 crashes must not trip the breaker (threshold is >3 per UQ-WP2-10)" + ); + } + + /// `breaker_03`: 4th crash trips the breaker. + #[test] + fn breaker_03_above_threshold_trips() { + let mut b = CrashLoopBreaker::default(); + let t = Instant::now(); + for _ in 0..3 { + b.record_crash_at(t); + } + let state = b.record_crash_at(t); + assert_eq!( + state, + CrashLoopState::Tripped, + "4th crash must trip the breaker" + ); + } + + /// `breaker_04`: 4 crashes at t0; advance 61 s and record 1 more → Open + /// (old crashes pruned out of the window). + #[test] + fn breaker_04_old_crashes_pruned() { + let mut b = CrashLoopBreaker::default(); + let t0 = Instant::now(); + let t1 = t0 + Duration::from_secs(61); // outside the 60 s window + + for _ in 0..4 { + b.record_crash_at(t0); + } + // Breaker is tripped at t0. + // Now record one crash at t1 — t0 events age out; only this new one remains. + let state = b.record_crash_at(t1); + assert_eq!( + state, + CrashLoopState::Open, + "after pruning 4 old events, 1 within-window crash must leave breaker Open" + ); + } + + /// `breaker_05`: `CrashLoopBreaker::default()` uses documented constants. + #[test] + fn breaker_05_default_values() { + assert_eq!( + CrashLoopBreaker::DEFAULT_WINDOW, + Duration::from_secs(60), + "DEFAULT_WINDOW must be 60 s per ADR-002 + UQ-WP2-10" + ); + assert_eq!( + CrashLoopBreaker::DEFAULT_THRESHOLD, + 3, + "DEFAULT_THRESHOLD must be 3 per UQ-WP2-10" + ); + // Also verify the Default impl delegates to new(). + let b = CrashLoopBreaker::default(); + assert_eq!(b.window, CrashLoopBreaker::DEFAULT_WINDOW); + assert_eq!(b.threshold, CrashLoopBreaker::DEFAULT_THRESHOLD); + } + + // ── Integration test with MockPlugin::new_crashing ──────────────────────── + + /// `breaker_06`: simulate the production crash-loop pattern using + /// `MockPlugin::new_crashing`. + /// + /// The crashing mock transitions to Crashed after the `initialized` + /// notification; all further frames are silently dropped. We drive the + /// mock at the transport layer (`write_frame` / `read_frame` / tick) rather + /// than through `PluginHost`, because `PluginHost`'s private fields are not + /// accessible from this module. + /// + /// Each cycle: + /// 1. Build a fresh `MockPlugin::new_crashing()`. + /// 2. Send `initialize` + tick → read back the initialize response. + /// 3. Send `initialized` notification + tick → mock transitions to Crashed. + /// 4. Send `analyze_file` + tick → mock silently drops it; the outbox + /// has grown by zero bytes since the initialized notification. + /// This zero-byte response is the "crash" signal. + /// 5. Treat the absence of a response as a crash: `record_crash()`. + /// + /// Assert: after the 4th cycle the breaker is Tripped; the 5th cycle + /// pre-checks `state()` and skips the mock drive entirely. + #[test] + fn breaker_06_mock_crash_loop_trips_breaker() { + use crate::plugin::limits::ContentLengthCeiling; + use crate::plugin::mock::MockPlugin; + use crate::plugin::protocol::{ + AnalyzeFileParams, InitializeParams, InitializedNotification, make_notification, + make_request, + }; + use crate::plugin::transport::{Frame, read_frame, write_frame}; + + let mut breaker = CrashLoopBreaker::default(); + let mut crashes_recorded: usize = 0; + + for cycle in 1..=5_usize { + // Pre-spawn state check: once tripped, refuse further attempts. + if breaker.state() == CrashLoopState::Tripped { + assert!( + crashes_recorded >= 4, + "cycle {cycle}: breaker tripped before 4 recorded crashes (got {crashes_recorded})" + ); + continue; + } + + let mut mock = MockPlugin::new_crashing(); + + // Step 1: send initialize request. + let init_req = make_request( + "initialize", + &InitializeParams { + protocol_version: "1.0".to_owned(), + project_root: "/tmp/clarion-breaker-test".to_owned(), + }, + 1, + ); + write_frame( + mock.stdin(), + &Frame { + body: serde_json::to_vec(&init_req).expect("serialise initialize"), + }, + ) + .expect("write initialize"); + mock.tick().expect("tick after initialize"); + + // Step 2: read initialize response — crashing mock responds normally here. + let _init_resp_frame = + read_frame(mock.stdout(), ContentLengthCeiling::new(1024 * 1024)).unwrap_or_else( + |e| panic!("cycle {cycle}: crashing mock must respond to initialize: {e}"), + ); + + // Step 3: send initialized notification → mock transitions to Crashed. + let init_note = make_notification("initialized", &InitializedNotification {}); + write_frame( + mock.stdin(), + &Frame { + body: serde_json::to_vec(&init_note).expect("serialise initialized"), + }, + ) + .expect("write initialized"); + mock.tick().expect("tick after initialized"); + + // Record outbox position after the handshake. + let pos_after_handshake = mock.stdout().get_ref().len() as u64; + + // Step 4: send analyze_file → Crashed mock silently drops it. + let af_req = make_request( + "analyze_file", + &AnalyzeFileParams { + file_path: "/tmp/clarion-breaker-test/stub.mock".to_owned(), + }, + 2, + ); + write_frame( + mock.stdin(), + &Frame { + body: serde_json::to_vec(&af_req).expect("serialise analyze_file"), + }, + ) + .expect("write analyze_file"); + mock.tick() + .expect("tick after analyze_file (crashing mock)"); + + // The outbox must not have grown — crashed mock produces no response. + let pos_after_analyze = mock.stdout().get_ref().len() as u64; + assert_eq!( + pos_after_analyze, pos_after_handshake, + "cycle {cycle}: crashing mock must produce no response to analyze_file \ + (outbox grew from {pos_after_handshake} to {pos_after_analyze})" + ); + + // The absence of an analyze_file response is the "crash" signal. + // In production the host's read_frame would block / return EOF; + // here we treat the zero outbox growth as equivalent. + let state = breaker.record_crash(); + crashes_recorded += 1; + + if cycle == 3 { + assert_eq!( + state, + CrashLoopState::Open, + "cycle {cycle}: 3 crashes must not trip the breaker (threshold is >3)" + ); + } + if cycle == 4 { + assert_eq!( + state, + CrashLoopState::Tripped, + "cycle {cycle}: 4th crash must trip the breaker" + ); + } + } + + assert_eq!( + crashes_recorded, 4, + "must have recorded exactly 4 crashes before breaker refused the 5th cycle" + ); + assert_eq!( + breaker.state(), + CrashLoopState::Tripped, + "breaker must be Tripped after 4 crashes" + ); + } +} diff --git a/crates/clarion-core/src/plugin/discovery.rs b/crates/clarion-core/src/plugin/discovery.rs new file mode 100644 index 00000000..a095952e --- /dev/null +++ b/crates/clarion-core/src/plugin/discovery.rs @@ -0,0 +1,637 @@ +//! Plugin discovery via `$PATH` scanning (ADR-021 §L9). +//! +//! # Matching rule +//! +//! A file is a Clarion plugin candidate if its name matches +//! `clarion-plugin-` where `` is at least one character +//! consisting solely of `[A-Za-z0-9_-]`. Names such as `clarion-plugin-` +//! (empty suffix) or `clarion-plugin` (no second hyphen) are rejected. +//! +//! Additionally the file must exist, be a regular file, and — on Unix — have +//! at least one executable bit set (`mode & 0o111 != 0`). +//! +//! # Manifest lookup order +//! +//! For an executable at `/clarion-plugin-`: +//! +//! 1. **Neighbor first**: `/plugin.toml`. +//! 2. **Install-prefix fallback** (only when `` has basename `bin`): +//! `/../share/clarion/plugins//plugin.toml`. +//! 3. Neither found → [`DiscoveryError::ManifestNotFound`]. +//! +//! **Limitation**: when multiple `clarion-plugin-*` binaries share the same +//! directory (e.g. `/usr/local/bin`), they all resolve to the *same* +//! neighbor `plugin.toml`. This is a known constraint of the neighbor +//! convention; real installs should use the install-prefix layout so each +//! plugin has its own `share/clarion/plugins//plugin.toml`. +//! +//! # Deduplication +//! +//! Duplicate `$PATH` directories are skipped. If the same binary name +//! appears in multiple directories the first occurrence wins (matching +//! POSIX shell / `which` semantics). + +use std::collections::HashSet; +use std::ffi::OsStr; +use std::io::Read; +use std::path::PathBuf; + +use thiserror::Error; + +use crate::plugin::{Manifest, ManifestError, parse_manifest}; + +// ── Public types ────────────────────────────────────────────────────────────── + +/// A plugin discovered via a `clarion-plugin-*` executable on `$PATH`. +#[derive(Debug)] +pub struct DiscoveredPlugin { + /// Path to the plugin executable **as found on `$PATH`**. + /// + /// Intentionally NOT canonicalised. The neighbour-manifest lookup + /// joins `plugin.toml` with this path's parent directory; + /// canonicalising here would follow symlinks (e.g. + /// `~/bin/clarion-plugin-python` → `~/.local/pipx/venvs/*/bin/...`) + /// and the manifest lookup would then miss the neighbour that lives + /// next to the symlink. + /// + /// Deduplication uses a separate canonicalised key + /// (`seen_dirs` inside [`discover_on_path`]), so the raw-path retained + /// here does not defeat shadowing. + /// + /// If you need the real binary location for an operator message (e.g. + /// "this plugin's binary is actually at …"), canonicalise at the point + /// of use; discovery keeps the raw form so downstream consumers can + /// make the decision. + pub executable: PathBuf, + /// Parsed manifest from the plugin's `plugin.toml`. + pub manifest: Manifest, + /// Location from which the manifest was loaded (for error messages). + pub manifest_path: PathBuf, +} + +/// Errors produced during plugin discovery. +/// +/// Each variant corresponds to a single `clarion-plugin-*` binary; a +/// failure for one plugin does **not** suppress results for others. +#[derive(Debug, Error)] +pub enum DiscoveryError { + /// A `clarion-plugin-*` binary was found on `$PATH` but no `plugin.toml` + /// was found at either the neighbor location or the install-prefix + /// location. + #[error( + "no plugin.toml found for {executable} \ + (searched neighbor dir and install-prefix share/)" + )] + ManifestNotFound { executable: PathBuf }, + + /// The manifest file was found but parse/validation failed. + #[error("plugin.toml at {path} failed to parse: {source}")] + ManifestInvalid { + path: PathBuf, + #[source] + source: ManifestError, + }, + + /// An I/O error occurred while reading the manifest file. + #[error("io error reading {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + /// The manifest file exceeded [`MAX_MANIFEST_BYTES`]. Real `plugin.toml` + /// files are under 2 KiB; anything over 64 KiB is pathological and is + /// refused before it reaches the TOML parser. + #[error("plugin.toml at {path} exceeded max size {MAX_MANIFEST_BYTES} bytes")] + ManifestTooLarge { path: PathBuf }, + + /// A `$PATH` directory is world-writable. Any user with write + /// access could drop a `clarion-plugin-*` binary into it. Refused + /// to preserve the ADR-021 "semi-trusted plugin" model — operator + /// must deliberately install plugins. + #[error( + "plugin directory {path} is world-writable and refused for security; \ + install plugins in a 0o755 directory (~/.local/bin, /usr/local/bin)" + )] + WorldWritableDir { path: PathBuf }, +} + +/// Maximum accepted `plugin.toml` size. Real manifests are well under 2 KiB; +/// 64 KiB is a trust-boundary cap, not a style constraint. Discovery refuses +/// anything larger before attempting a TOML parse. +pub const MAX_MANIFEST_BYTES: u64 = 64 * 1024; + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Discover plugins on the user's `$PATH`. +/// +/// Reads `$PATH` from the process environment and delegates to +/// [`discover_on_path`]. Returns one `Result` per `clarion-plugin-*` +/// binary found. +#[cfg(unix)] +pub fn discover() -> Vec> { + let path_val = std::env::var_os("PATH").unwrap_or_default(); + discover_on_path(&path_val) +} + +#[cfg(not(unix))] +pub fn discover() -> Vec> { + vec![] +} + +/// Discover plugins on the given explicit `PATH` value (useful in tests). +/// +/// Parses `path_env` using [`std::env::split_paths`], then scans each +/// directory for `clarion-plugin-*` executables. Returns one `Result` per +/// candidate found; a broken plugin does not suppress its siblings. +/// +/// **Note**: if two `clarion-plugin-*` binaries sharing a directory both +/// try to use the neighbor `plugin.toml`, they will resolve to the *same* +/// file. This is expected behaviour given the neighbor convention; see the +/// module-level docs for the recommended install-prefix layout. +/// +/// Primarily useful for testing; production callers should use [`discover`]. +#[cfg(unix)] +pub fn discover_on_path(path_env: &OsStr) -> Vec> { + let mut results = Vec::new(); + let mut seen_dirs: HashSet = HashSet::new(); + let mut seen_names: HashSet = HashSet::new(); + + for dir in std::env::split_paths(path_env) { + // Skip empty entries (POSIX: empty means cwd — we don't support that). + if dir.as_os_str().is_empty() { + continue; + } + + // Deduplicate directories. + let canonical_dir = match dir.canonicalize() { + Ok(c) => c, + // If the dir doesn't exist or can't be canonicalised, still use the + // raw path for dedup so we don't skip a later entry that resolves + // differently. + Err(_) => dir.clone(), + }; + if !seen_dirs.insert(canonical_dir.clone()) { + continue; + } + + // Refuse to load plugins from world-writable directories. On a + // multi-user machine, any user with write access to a $PATH dir + // becomes a plugin installer — a threat model the hybrid- + // authority framing (ADR-021) rules out. Production installs + // should use `~/.local/bin` (0o755) or `/usr/local/bin` (0o755); + // only pathologically misconfigured dirs fail this check. + if is_world_writable(&dir) { + results.push(Err(DiscoveryError::WorldWritableDir { path: dir.clone() })); + continue; + } + + // Read directory entries; skip silently on I/O error (non-existent + // dirs are common in $PATH). + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + + for entry_result in entries { + let Ok(entry) = entry_result else { + continue; + }; + + // non-UTF-8 names can't match our prefix. + let Ok(file_name) = entry.file_name().into_string() else { + continue; + }; + + // ── Name filter ─────────────────────────────────────────────────── + let suffix = match extract_plugin_suffix(&file_name) { + Some(s) => s.to_owned(), + None => continue, + }; + + // ── Shadowing: first match wins ─────────────────────────────────── + // Safe to key on String: non-UTF-8 names were filtered above. + if !seen_names.insert(file_name.clone()) { + continue; + } + + // `exec_path` is the raw PATH-relative path (not canonicalised). + // Do not canonicalise here — the neighbour-manifest convention + // at `load_plugin` / `find_manifest` looks up `plugin.toml` next + // to this path, and a symlink install pattern (e.g. `~/bin/` full + // of symlinks into `~/.local/pipx/venvs/*/bin/`) expects the + // manifest to live next to the symlink, not next to the resolved + // binary in the venv. See the `executable` field doc-comment on + // `DiscoveredPlugin` for the full consistency story. + let exec_path = dir.join(&file_name); + + // ── Exec-bit check ──────────────────────────────────────────────── + if !is_executable(&exec_path) { + continue; + } + + // ── Manifest lookup ─────────────────────────────────────────────── + results.push(load_plugin(exec_path, &suffix)); + } + } + + results +} + +#[cfg(not(unix))] +pub fn discover_on_path(_path_env: &OsStr) -> Vec> { + vec![] +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +/// Extract the `` from a `clarion-plugin-` name, or `None`. +/// +/// Suffix must be at least one character and consist only of `[A-Za-z0-9_-]`. +fn extract_plugin_suffix(name: &str) -> Option<&str> { + let suffix = name.strip_prefix("clarion-plugin-")?; + if suffix.is_empty() { + return None; + } + if suffix + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + Some(suffix) + } else { + None + } +} + +/// Return `true` if `path` is a regular file with at least one exec bit set. +#[cfg(unix)] +fn is_executable(path: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + match std::fs::metadata(path) { + Ok(meta) => meta.is_file() && (meta.permissions().mode() & 0o111 != 0), + Err(_) => false, + } +} + +/// Return `true` if `path` has world-write permission set. Returns +/// `false` on metadata errors (the caller treats unreachable +/// directories as "skip silently", not "world-writable"). +#[cfg(unix)] +fn is_world_writable(path: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path).is_ok_and(|meta| meta.permissions().mode() & 0o002 != 0) +} + +#[cfg(not(unix))] +fn is_world_writable(_path: &std::path::Path) -> bool { + false +} + +/// Load the manifest for a plugin at `exec_path` with binary-name suffix `suffix`. +fn load_plugin(exec_path: PathBuf, suffix: &str) -> Result { + let manifest_path = find_manifest(&exec_path, suffix)?; + + // Cap the read size BEFORE the TOML parser sees the bytes. A plugin.toml + // is under 2 KiB in practice; the 64 KiB cap accepts everything legitimate + // while rejecting pathological payloads that would otherwise force the + // TOML parser to build an unbounded `toml::Value` tree. + let file = std::fs::File::open(&manifest_path).map_err(|e| DiscoveryError::Io { + path: manifest_path.clone(), + source: e, + })?; + // Read one byte past the cap so we can distinguish "at cap" from "over cap". + let mut bytes = Vec::with_capacity(4096); + file.take(MAX_MANIFEST_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| DiscoveryError::Io { + path: manifest_path.clone(), + source: e, + })?; + if bytes.len() as u64 > MAX_MANIFEST_BYTES { + return Err(DiscoveryError::ManifestTooLarge { + path: manifest_path, + }); + } + + let manifest = parse_manifest(&bytes).map_err(|e| DiscoveryError::ManifestInvalid { + path: manifest_path.clone(), + source: e, + })?; + + Ok(DiscoveredPlugin { + executable: exec_path, + manifest, + manifest_path, + }) +} + +/// Probe whether `path` is a regular file, distinguishing EACCES from `NotFound`. +/// +/// Returns: +/// - `Ok(Some(path))` — exists and is a regular file. +/// - `Ok(None)` — does not exist, or exists but is not a regular file (e.g. a +/// directory or a symlink that resolves to a directory). +/// - `Err(DiscoveryError::Io { … })` — any other I/O error, including EACCES. +/// This surfaces operator misconfigurations that `Path::is_file()` would +/// silently hide (it returns `false` on permission-denied). +fn probe_manifest(path: &std::path::Path) -> Result, DiscoveryError> { + match std::fs::metadata(path) { + Ok(m) if m.is_file() => Ok(Some(path.to_owned())), + Ok(_) => Ok(None), // exists but not a regular file (e.g. directory or symlink-to-dir) + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(DiscoveryError::Io { + path: path.to_owned(), + source: e, + }), + } +} + +/// Resolve the `plugin.toml` path for a given executable, or return +/// [`DiscoveryError::ManifestNotFound`]. +fn find_manifest(exec_path: &std::path::Path, suffix: &str) -> Result { + // 1. Neighbor: /plugin.toml + if let Some(parent) = exec_path.parent() { + let neighbor = parent.join("plugin.toml"); + if let Some(found) = probe_manifest(&neighbor)? { + return Ok(found); + } + + // 2. Install-prefix fallback: only when parent dir basename is "bin". + let parent_name = parent.file_name().and_then(|n| n.to_str()); + if parent_name == Some("bin") { + if let Some(grandparent) = parent.parent() { + let share_path = grandparent + .join("share") + .join("clarion") + .join("plugins") + .join(suffix) + .join("plugin.toml"); + if let Some(found) = probe_manifest(&share_path)? { + return Ok(found); + } + } + } + } + + Err(DiscoveryError::ManifestNotFound { + executable: exec_path.to_owned(), + }) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(all(test, unix))] +mod tests { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + use tempfile::TempDir; + + use super::*; + + // ── Fixture ─────────────────────────────────────────────────────────────── + + fn minimal_manifest_toml(plugin_id: &str) -> String { + format!( + r#"[plugin] +name = "clarion-plugin-{plugin_id}" +plugin_id = "{plugin_id}" +version = "0.1.0" +protocol_version = "1.0" +executable = "clarion-plugin-{plugin_id}" +language = "{plugin_id}" +extensions = ["mt"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["function"] +edge_kinds = ["calls"] +rule_id_prefix = "CLA-MT-" +ontology_version = "0.1.0" +"# + ) + } + + /// Write a file and make it executable. + fn make_executable(path: &std::path::Path) { + fs::write(path, b"#!/bin/sh\n").unwrap(); + let mut perms = fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).unwrap(); + } + + /// Write a file without exec bit (mode 0o644). + fn make_plain_file(path: &std::path::Path, content: &[u8]) { + fs::write(path, content).unwrap(); + let mut perms = fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o644); + fs::set_permissions(path, perms).unwrap(); + } + + /// Build an `OsString` representing a `$PATH`-style list from one or more dirs. + fn path_os(dirs: &[&std::path::Path]) -> std::ffi::OsString { + std::env::join_paths(dirs).unwrap() + } + + // ── T1: neighbor manifest found ─────────────────────────────────────────── + + #[test] + fn t1_neighbor_manifest_found() { + let tmp = TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + fs::create_dir_all(&bin).unwrap(); + + make_executable(&bin.join("clarion-plugin-mocktest")); + fs::write(bin.join("plugin.toml"), minimal_manifest_toml("mocktest")).unwrap(); + + let results = discover_on_path(&path_os(&[&bin])); + assert_eq!(results.len(), 1, "expected exactly one result"); + + let plugin = results.into_iter().next().unwrap().unwrap(); + assert_eq!(plugin.manifest.plugin.plugin_id, "mocktest"); + assert_eq!(plugin.executable, bin.join("clarion-plugin-mocktest")); + assert_eq!(plugin.manifest_path, bin.join("plugin.toml")); + } + + // ── T2: install-prefix fallback ─────────────────────────────────────────── + + #[test] + fn t2_install_prefix_fallback() { + let tmp = TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + fs::create_dir_all(&bin).unwrap(); + + make_executable(&bin.join("clarion-plugin-mocktest")); + // No neighbor plugin.toml — only the share/ location. + let share = tmp + .path() + .join("share") + .join("clarion") + .join("plugins") + .join("mocktest"); + fs::create_dir_all(&share).unwrap(); + fs::write(share.join("plugin.toml"), minimal_manifest_toml("mocktest")).unwrap(); + + let results = discover_on_path(&path_os(&[&bin])); + assert_eq!(results.len(), 1); + + let plugin = results.into_iter().next().unwrap().unwrap(); + assert_eq!(plugin.manifest.plugin.plugin_id, "mocktest"); + assert_eq!( + plugin.manifest_path, + tmp.path() + .join("share/clarion/plugins/mocktest/plugin.toml") + ); + } + + // ── T3: no manifest anywhere → ManifestNotFound ─────────────────────────── + + #[test] + fn t3_no_manifest_returns_manifest_not_found() { + let tmp = TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + fs::create_dir_all(&bin).unwrap(); + + make_executable(&bin.join("clarion-plugin-orphan")); + + let results = discover_on_path(&path_os(&[&bin])); + assert_eq!(results.len(), 1); + + let err = results.into_iter().next().unwrap().unwrap_err(); + assert!( + matches!(err, DiscoveryError::ManifestNotFound { .. }), + "expected ManifestNotFound, got: {err:?}" + ); + } + + // ── T4: malformed manifest → ManifestInvalid ───────────────────────────── + + #[test] + fn t4_malformed_manifest_returns_manifest_invalid() { + let tmp = TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + fs::create_dir_all(&bin).unwrap(); + + make_executable(&bin.join("clarion-plugin-broken")); + fs::write(bin.join("plugin.toml"), b"this is not valid toml ][[[").unwrap(); + + let results = discover_on_path(&path_os(&[&bin])); + assert_eq!(results.len(), 1); + + let err = results.into_iter().next().unwrap().unwrap_err(); + assert!( + matches!(err, DiscoveryError::ManifestInvalid { .. }), + "expected ManifestInvalid, got: {err:?}" + ); + } + + // ── T5: non-matching names skipped ──────────────────────────────────────── + + #[test] + fn t5_non_matching_names_skipped() { + let tmp = TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + fs::create_dir_all(&bin).unwrap(); + + // Should NOT match: + make_executable(&bin.join("not-clarion-plugin")); + make_executable(&bin.join("clarion-plugin-")); // empty suffix + make_executable(&bin.join("clarion-plugin")); // no second hyphen + + // Should match: + make_executable(&bin.join("clarion-plugin-valid")); + fs::write(bin.join("plugin.toml"), minimal_manifest_toml("valid")).unwrap(); + + let results = discover_on_path(&path_os(&[&bin])); + assert_eq!(results.len(), 1, "only one name should match"); + + let plugin = results.into_iter().next().unwrap().unwrap(); + assert_eq!(plugin.manifest.plugin.plugin_id, "valid"); + } + + // ── T6: non-executable file skipped ─────────────────────────────────────── + + #[test] + fn t6_non_executable_file_skipped() { + let tmp = TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + fs::create_dir_all(&bin).unwrap(); + + // File exists but has no exec bit. + make_plain_file(&bin.join("clarion-plugin-noexec"), b"#!/bin/sh\n"); + fs::write(bin.join("plugin.toml"), minimal_manifest_toml("noexec")).unwrap(); + + let results = discover_on_path(&path_os(&[&bin])); + assert_eq!(results.len(), 0, "non-executable should be skipped"); + } + + // ── T7: multiple $PATH entries, shadowing ───────────────────────────────── + + #[test] + fn t7_path_shadowing_first_wins() { + let tmp = TempDir::new().unwrap(); + let dir_a = tmp.path().join("a").join("bin"); + let dir_b = tmp.path().join("b").join("bin"); + fs::create_dir_all(&dir_a).unwrap(); + fs::create_dir_all(&dir_b).unwrap(); + + // Both dirs have the same binary name with valid manifests. + make_executable(&dir_a.join("clarion-plugin-dup")); + fs::write(dir_a.join("plugin.toml"), minimal_manifest_toml("dup")).unwrap(); + + make_executable(&dir_b.join("clarion-plugin-dup")); + fs::write(dir_b.join("plugin.toml"), minimal_manifest_toml("dup")).unwrap(); + + let results = discover_on_path(&path_os(&[dir_a.as_path(), dir_b.as_path()])); + assert_eq!( + results.len(), + 1, + "duplicate name should produce only one result" + ); + + let plugin = results.into_iter().next().unwrap().unwrap(); + // Executable must come from dir_a, not dir_b. + assert_eq!(plugin.executable, dir_a.join("clarion-plugin-dup")); + } + + // ── T8: world-writable directory refused ────────────────────────────────── + + /// A `$PATH` directory with world-write permission is refused with + /// [`DiscoveryError::WorldWritableDir`] and its contents are not + /// scanned. Protects the ADR-021 "semi-trusted plugin" model from + /// the multi-user-machine drop-in-evil-binary threat. + #[test] + fn t8_world_writable_dir_is_refused() { + let dir = TempDir::new().unwrap(); + make_executable(&dir.path().join("clarion-plugin-evil")); + fs::write( + dir.path().join("plugin.toml"), + minimal_manifest_toml("evil"), + ) + .unwrap(); + + // Make the dir world-writable. + let mut perms = fs::metadata(dir.path()).unwrap().permissions(); + perms.set_mode(0o777); + fs::set_permissions(dir.path(), perms).unwrap(); + + let results = discover_on_path(&path_os(&[dir.path()])); + assert_eq!( + results.len(), + 1, + "world-writable dir must produce one error" + ); + let err = results.into_iter().next().unwrap().unwrap_err(); + match err { + DiscoveryError::WorldWritableDir { path } => { + assert_eq!(path, dir.path(), "error must name the offending dir"); + } + other => panic!("expected WorldWritableDir; got {other:?}"), + } + } +} diff --git a/crates/clarion-core/src/plugin/host.rs b/crates/clarion-core/src/plugin/host.rs new file mode 100644 index 00000000..b649dcd3 --- /dev/null +++ b/crates/clarion-core/src/plugin/host.rs @@ -0,0 +1,2493 @@ +//! Plugin-host supervisor. +//! +//! Implements ADR-021 §Layer 2 core-enforced minimums plus the ADR-022 ontology +//! boundary and UQ-WP2-11 identity-mismatch check. +//! +//! # Overview +//! +//! `PluginHost` is generic over `R: BufRead` and `W: Write` so unit tests can +//! drive it with an in-process mock without spawning a real subprocess. +//! +//! # Enforcement pipeline (per entity in `analyze_file` response) +//! +//! 1. **Ontology check** (ADR-022): `entity.kind` must be in +//! `manifest.ontology.entity_kinds`. Violation → drop + finding; no kill. +//! 2. **Identity check** (UQ-WP2-11): `entity_id(plugin_id, kind, qualified_name)` +//! must equal the returned `entity.id` string. Mismatch → drop + finding; no kill. +//! 3. **Jail check** (ADR-021 §2a): `entity.source.file_path` must canonicalise +//! inside `project_root`. Escape → drop + finding; tick [`PathEscapeBreaker`]. +//! Breaker tripped → kill plugin, return [`HostError::PathEscapeBreakerTripped`]. +//! 4. **Entity cap check** (ADR-021 §2c): run-cumulative count must stay ≤ 500k. +//! Exceeded → kill plugin, return [`HostError::EntityCapExceeded`]. +//! +//! # Memory limit +//! +//! On Linux, [`PluginHost::spawn`] calls [`apply_prlimit_as`] inside +//! `CommandExt::pre_exec` to set `RLIMIT_AS` before `exec()`. The closure body +//! only calls `setrlimit(2)`, which is async-signal-safe per POSIX.1-2017 +//! §2.4.3. The `unsafe` block is the minimum required by the `pre_exec` API. + +use std::collections::BTreeMap; +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +use crate::entity_id::{EntityId, EntityIdError, entity_id}; +use crate::plugin::jail::{JailError, jail_to_string}; +use crate::plugin::limits::{ + BreakerState, CapExceeded, ContentLengthCeiling, DEFAULT_MAX_NOFILE, DEFAULT_MAX_NPROC, + DEFAULT_MAX_RSS_MIB, EntityCountCap, FINDING_DISABLED_PATH_ESCAPE, FINDING_ENTITY_CAP, + FINDING_OOM_KILLED, FINDING_PATH_ESCAPE, PathEscapeBreaker, apply_prlimit_as, + apply_prlimit_nofile_nproc, effective_rss_mib, +}; +use crate::plugin::manifest::{Manifest, ManifestError}; +use crate::plugin::protocol::{ + AnalyzeFileParams, AnalyzeFileResult, ExitNotification, InitializeParams, InitializeResult, + InitializedNotification, ProtocolError, ResponseEnvelope, ResponsePayload, ShutdownParams, + make_notification, make_request, +}; +use crate::plugin::transport::{Frame, TransportError, read_frame, write_frame}; + +// ── Host-level finding subcodes ─────────────────────────────────────────────── +// +// Resource and framing findings live in `limits.rs` next to the types they +// reference (ContentLengthCeiling, EntityCountCap, etc.). The three subcodes +// below cover protocol / ontology / manifest-capability failures, which are +// supervisor-level concerns — they have no natural home in limits.rs. + +/// Emitted when a plugin emits an entity whose `kind` is not in the manifest's +/// `entity_kinds` list (ADR-022 ontology boundary). +pub const FINDING_UNDECLARED_KIND: &str = "CLA-INFRA-PLUGIN-UNDECLARED-KIND"; + +/// Emitted when a plugin emits an entity whose `id` string does not match the +/// expected `entity_id(plugin_id, kind, qualified_name)` (UQ-WP2-11). +pub const FINDING_ENTITY_ID_MISMATCH: &str = "CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH"; + +/// Emitted when the manifest contains a capability not supported in v0.1 +/// (ADR-021 §Layer 1). +pub const FINDING_UNSUPPORTED_CAPABILITY: &str = "CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY"; + +/// Emitted when a plugin returns an entity whose JSON shape fails to +/// deserialise into [`RawEntity`] (missing required field, wrong type, etc.). +/// +/// Structurally invalid entities are dropped rather than failing the run, so +/// the finding is the only signal the operator gets that the plugin emitted +/// malformed output. Without this, a plugin bug that silently produces +/// garbage for a subset of entities looks identical to "no entities found". +pub const FINDING_MALFORMED_ENTITY: &str = "CLA-INFRA-PLUGIN-MALFORMED-ENTITY"; + +/// Emitted when the host is asked to analyze a file whose path is not +/// representable as UTF-8. The wire protocol is JSON (UTF-8 only), so the +/// host cannot forward the path to the plugin; the file is skipped with +/// this finding and the run continues. +/// +/// Linux filenames are arbitrary byte sequences. Using `to_string_lossy` +/// at the wire boundary would replace invalid bytes with U+FFFD, yielding +/// a path the plugin cannot open and an obscure "plugin returned no +/// entities" symptom. Failing loudly with this finding keeps the +/// diagnostic at the host layer. +pub const FINDING_NON_UTF8_PATH: &str = "CLA-INFRA-HOST-NON-UTF8-PATH"; + +/// Emitted when a plugin returns an entity with a string field longer than +/// [`MAX_ENTITY_FIELD_BYTES`]. Entity is dropped; plugin is not killed. +/// +/// Without this bound, a plugin could emit up to [`crate::plugin::limits::EntityCountCap`] +/// entities each carrying multi-MB `qualified_name`/`kind`/`id`/`file_path` strings. +/// The identity check at `host.rs` duplicates `qualified_name` through `format!()`, +/// so the memory cost is ≥2× the incoming string per offending entity, making +/// this a RAM-amplification vector even under the 8 MiB Content-Length ceiling +/// (which bounds a single frame, not the run-cumulative total). +pub const FINDING_ENTITY_FIELD_OVERSIZE: &str = "CLA-INFRA-PLUGIN-ENTITY-FIELD-OVERSIZE"; + +/// Per-string length cap applied to [`RawEntity::id`], [`RawEntity::kind`], +/// [`RawEntity::qualified_name`], and [`RawSource::file_path`]. +/// +/// 4 KiB is well above any legitimate identifier or path in a real codebase +/// (the Linux `PATH_MAX` is 4096; Python fully-qualified names exceeding 1 KiB +/// are absent from elspeth's 425k LOC baseline). The cap is a trust-boundary +/// check, not a style constraint — pick a value that rejects `DoS` payloads +/// without false-positing on pathological-but-legitimate inputs. +pub const MAX_ENTITY_FIELD_BYTES: usize = 4 * 1024; + +/// Per-entity cap on the total serialised size of the untyped passthrough +/// maps [`RawEntity::extra`] and [`RawSource::extra`]. +/// +/// These flow into `properties_json` downstream (via +/// `clarion-cli::analyze::map_entity_to_record`) as `serde_json::to_string` +/// output. Without a cap, a plugin could return 8 MiB frames consisting of +/// one tiny `qualified_name` plus a multi-MiB `extra` map that lives in the +/// database row and in every host-side clone until the run ends. 64 KiB is +/// well above any legitimate plugin-declared properties bag (WP3's wardline +/// payload is <2 KiB) while rejecting payload floods. +pub const MAX_ENTITY_EXTRA_BYTES: usize = 64 * 1024; + +// ── Wire entity types (Option A) ────────────────────────────────────────────── + +/// Raw entity as received from the plugin wire. +/// +/// Deserialised directly from the `entities` array in `AnalyzeFileResult`. +/// Surviving entities become [`AcceptedEntity`] values after validation. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct RawEntity { + /// Three-segment entity ID: `plugin_id:kind:qualified_name`. + pub id: String, + /// Entity kind, e.g. `"function"`. + pub kind: String, + /// Canonical qualified name, e.g. `"auth.tokens.refresh"`. + pub qualified_name: String, + /// Source location. + pub source: RawSource, + /// Extra fields — accepted without interpretation. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// Source location from the wire entity. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct RawSource { + /// Absolute or project-relative path. Subject to the path jail. + pub file_path: String, + /// Extra source fields — accepted without interpretation. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// Return `Some((field_name, actual_len))` for the first field of `raw` that +/// exceeds its bound, or `None` if every field is in-bounds. +/// +/// Fields are checked in a stable order so the finding reports the first +/// offender deterministically for the same input. Order mirrors the wire +/// layout: `id` → `kind` → `qualified_name` → `source.file_path` → +/// `extra` (serialised) → `source.extra` (serialised). The four scalar +/// string fields are bounded by [`MAX_ENTITY_FIELD_BYTES`]; the two +/// untyped passthrough maps are bounded by [`MAX_ENTITY_EXTRA_BYTES`]. +fn oversize_field(raw: &RawEntity) -> Option<(&'static str, usize)> { + for (name, len) in [ + ("id", raw.id.len()), + ("kind", raw.kind.len()), + ("qualified_name", raw.qualified_name.len()), + ("source.file_path", raw.source.file_path.len()), + ] { + if len > MAX_ENTITY_FIELD_BYTES { + return Some((name, len)); + } + } + + // `extra` and `source.extra` flow to `properties_json` downstream. The + // check is by serialised byte length rather than entry count — a single + // entry with a multi-MiB Value is as toxic as many entries each small. + // Serialisation is the next-downstream step anyway (via + // clarion-cli::analyze::map_entity_to_record), so the to_vec here is not + // an additional allocation beyond what we were already going to pay. + for (name, map) in [("extra", &raw.extra), ("source.extra", &raw.source.extra)] { + if map.is_empty() { + continue; + } + let len = serde_json::to_vec(map).map_or(0, |b| b.len()); + if len > MAX_ENTITY_EXTRA_BYTES { + return Some((name, len)); + } + } + + None +} + +/// An entity that has passed all validation checks. +/// +/// Returned by [`PluginHost::analyze_file`] for each entity that survived the +/// ontology, identity, jail, and cap checks. +#[derive(Debug, Clone)] +pub struct AcceptedEntity { + /// Parsed and validated entity ID. + pub id: EntityId, + /// Kind (matches `manifest.ontology.entity_kinds`). + pub kind: String, + /// Canonical qualified name. + pub qualified_name: String, + /// Jail-canonicalised, UTF-8 source path. + pub source_file_path: String, + /// The original raw entity (for downstream consumers, e.g. WP1 writer). + pub raw: RawEntity, +} + +// ── Error types ─────────────────────────────────────────────────────────────── + +/// Operational failures returned to the caller of `PluginHost` methods. +#[derive(Debug, Error)] +pub enum HostError { + /// Transport-layer failure (I/O or framing error). + #[error("transport: {0}")] + Transport(#[from] TransportError), + + /// Protocol violation (e.g. response id mismatch, error payload). + #[error("protocol error: code={}, message={}", .0.code, .0.message)] + Protocol(ProtocolError), + + /// Manifest capability check failed at handshake time. + #[error("manifest validation at handshake: {0}")] + Handshake(ManifestError), + + /// Run-cumulative entity cap exceeded; plugin was killed. + #[error("entity cap exceeded")] + EntityCapExceeded(#[source] CapExceeded), + + /// Path-escape circuit-breaker tripped; plugin was killed. + #[error("path-escape breaker tripped; plugin killed")] + PathEscapeBreakerTripped, + + /// JSON serialisation / deserialisation error. + #[error("json: {0}")] + Serde(#[from] serde_json::Error), + + /// Low-level I/O error not wrapped in a transport error. + #[error("io: {0}")] + Io(#[from] std::io::Error), + + /// Plugin spawn failed. + #[error("plugin spawn failed: {0}")] + Spawn(String), + + /// Entity ID construction error (malformed `plugin_id` or kind in manifest). + #[error("entity id error: {0}")] + EntityId(#[from] EntityIdError), +} + +impl From for HostError { + fn from(e: ManifestError) -> Self { + HostError::Handshake(e) + } +} + +/// Informational diagnostic accumulated during a host's lifetime. +/// +/// Collected into `self.findings` on each enforcement action. Drained via +/// [`PluginHost::take_findings`]. Will eventually be persisted as ADR-004 +/// Findings; for Sprint 1 they are collected only. +#[derive(Debug, Clone)] +pub struct HostFinding { + /// Finding subcode, e.g. `"CLA-INFRA-PLUGIN-PATH-ESCAPE"`. + pub subcode: &'static str, + /// Human-readable message. + pub message: String, + /// Structured metadata (keys: `"offending_path"`, `"entity_id"`, etc.). + pub metadata: BTreeMap, +} + +impl HostFinding { + fn undeclared_kind(kind: &str, qualified_name: &str) -> Self { + let mut metadata = BTreeMap::new(); + metadata.insert("kind".to_owned(), kind.to_owned()); + metadata.insert("qualified_name".to_owned(), qualified_name.to_owned()); + Self { + subcode: FINDING_UNDECLARED_KIND, + message: format!("entity kind {kind:?} is not declared in the manifest ontology"), + metadata, + } + } + + fn entity_id_mismatch(got: &str, expected: &str) -> Self { + let mut metadata = BTreeMap::new(); + metadata.insert("got".to_owned(), got.to_owned()); + metadata.insert("expected".to_owned(), expected.to_owned()); + Self { + subcode: FINDING_ENTITY_ID_MISMATCH, + message: format!("entity id mismatch: got {got:?}, expected {expected:?}"), + metadata, + } + } + + fn path_escape(offending_path: &str) -> Self { + let mut metadata = BTreeMap::new(); + metadata.insert("offending_path".to_owned(), offending_path.to_owned()); + Self { + subcode: FINDING_PATH_ESCAPE, + message: format!("entity source path escapes project root: {offending_path:?}"), + metadata, + } + } + + fn disabled_path_escape() -> Self { + Self { + subcode: FINDING_DISABLED_PATH_ESCAPE, + message: "path-escape circuit breaker tripped; plugin killed".to_owned(), + metadata: BTreeMap::new(), + } + } + + fn entity_cap_exceeded_finding(cap: usize, would_reach: usize) -> Self { + let mut metadata = BTreeMap::new(); + metadata.insert("cap".to_owned(), cap.to_string()); + metadata.insert("would_reach".to_owned(), would_reach.to_string()); + Self { + subcode: FINDING_ENTITY_CAP, + message: format!("entity cap {cap} would be exceeded (would reach {would_reach})"), + metadata, + } + } + + fn unsupported_capability(msg: &str) -> Self { + let mut metadata = BTreeMap::new(); + metadata.insert("detail".to_owned(), msg.to_owned()); + Self { + subcode: FINDING_UNSUPPORTED_CAPABILITY, + message: format!("manifest has unsupported capability: {msg}"), + metadata, + } + } + + fn non_utf8_path(lossy_repr: &str) -> Self { + let mut metadata = BTreeMap::new(); + metadata.insert("path_lossy".to_owned(), lossy_repr.to_owned()); + Self { + subcode: FINDING_NON_UTF8_PATH, + message: format!( + "file skipped: path is not valid UTF-8 and cannot be expressed \ + on the JSON wire protocol: {lossy_repr:?}" + ), + metadata, + } + } + + fn malformed_entity(serde_err: &str) -> Self { + let mut metadata = BTreeMap::new(); + metadata.insert("serde_error".to_owned(), serde_err.to_owned()); + Self { + subcode: FINDING_MALFORMED_ENTITY, + message: format!("plugin emitted an entity that failed to deserialise: {serde_err}"), + metadata, + } + } + + fn entity_field_oversize(field: &'static str, actual_bytes: usize) -> Self { + let mut metadata = BTreeMap::new(); + metadata.insert("field".to_owned(), field.to_owned()); + metadata.insert("actual_bytes".to_owned(), actual_bytes.to_string()); + metadata.insert("limit_bytes".to_owned(), MAX_ENTITY_FIELD_BYTES.to_string()); + Self { + subcode: FINDING_ENTITY_FIELD_OVERSIZE, + message: format!( + "entity field {field:?} is {actual_bytes} bytes, over the {MAX_ENTITY_FIELD_BYTES}-byte limit" + ), + metadata, + } + } + + /// Emitted by the CLI wrapper once the child has been reaped and its exit + /// status indicates a signal consistent with an `RLIMIT_AS` kill (SIGKILL + /// or SIGSEGV). Lives on [`HostFinding`] rather than being constructed in + /// the CLI so the finding-subcode API is centralised. + pub fn oom_killed(plugin_id: &str, signal: i32) -> Self { + let mut metadata = BTreeMap::new(); + metadata.insert("plugin_id".to_owned(), plugin_id.to_owned()); + metadata.insert("signal".to_owned(), signal.to_string()); + Self { + subcode: FINDING_OOM_KILLED, + message: format!( + "plugin {plugin_id} killed by signal {signal} \ + (likely RLIMIT_AS enforcement per ADR-021 §2d)" + ), + metadata, + } + } +} + +// ── PluginHost ──────────────────────────────────────────────────────────────── + +/// Supervisor managing a single plugin connection. +/// +/// Generic over `R: BufRead` and `W: Write` so tests can drive the host +/// in-process without a subprocess. +pub struct PluginHost +where + R: BufRead, + W: Write, +{ + manifest: Manifest, + project_root: PathBuf, + reader: R, + writer: W, + ceiling: ContentLengthCeiling, + entity_cap: EntityCountCap, + path_breaker: PathEscapeBreaker, + next_request_id: i64, + findings: Vec, + /// Set after the first successful `do_shutdown` or after a kill-path + /// shutdown in `analyze_file` (breaker trip / entity-cap exceeded). + /// A second `shutdown()` call becomes a no-op rather than writing to + /// a closed pipe and surfacing a spurious `BrokenPipe` error. + terminated: bool, + /// Ontology version advertised by the plugin in its `initialize` + /// response. `None` before handshake completes; `Some(...)` after a + /// successful handshake. + /// + /// Retained for ADR-007 cache keying (WP6). Sprint 1 validates only + /// that the field is present and non-empty — semver comparison is + /// WP6's job. + ontology_version: Option, + /// Bounded ring buffer holding the tail of the plugin's stderr. The + /// subprocess `spawn` constructor populates this from a detached + /// drain thread; the in-process `connect` constructor leaves it + /// `None`. Capacity is [`STDERR_TAIL_BYTES`]; oldest bytes are + /// discarded on overflow so the plugin cannot back-pressure the host + /// via stderr writes. + stderr_tail: Option>>>, +} + +/// Size of the stderr ring buffer kept for diagnostics. 64 KiB holds +/// ~800 lines of plugin output — enough for any realistic diagnostic +/// tail while capping the plugin's ability to exfiltrate or `DoS` via +/// stderr volume. +pub const STDERR_TAIL_BYTES: usize = 64 * 1024; + +/// Detached-thread body that reads the plugin's stderr in small chunks +/// and appends to the ring buffer, discarding oldest bytes on overflow. +/// Exits cleanly on EOF (child closes stderr) or on any read error. +#[cfg(unix)] +fn drain_stderr_into_ring( + mut stderr: std::process::ChildStderr, + ring: &std::sync::Arc>>, +) { + use std::io::Read; + let mut buf = [0u8; 4096]; + loop { + match stderr.read(&mut buf) { + Ok(0) => break, // EOF + Ok(n) => { + if let Ok(mut ring) = ring.lock() { + for &b in &buf[..n] { + if ring.len() >= STDERR_TAIL_BYTES { + ring.pop_front(); + } + ring.push_back(b); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => { + // Retry the read. + } + Err(_) => break, + } + } +} + +// ── Subprocess constructor ──────────────────────────────────────────────────── + +impl + PluginHost< + std::io::BufReader, + std::io::BufWriter, + > +{ + /// Spawn the plugin as a subprocess, apply `RLIMIT_AS` on Linux, perform + /// the handshake, and return the live host alongside the child handle. + /// + /// `executable` is the path discovered on `$PATH` (from + /// [`crate::plugin::DiscoveredPlugin::executable`]). The manifest's + /// `plugin.executable` field is validated to be a bare basename that + /// matches the discovered filename — a compromised `plugin.toml` + /// cannot redirect execution to `/bin/sh`, `python3`, or a relative + /// `../../.local/bin/evil` by naming it there. + /// + /// # Errors + /// + /// Returns [`HostError::Spawn`] if: + /// - the executable cannot be started; + /// - the manifest's declared `plugin.executable` contains a path + /// separator, or does not match the discovered binary's basename. + /// + /// Returns a handshake error if the plugin fails `initialize` or the + /// manifest fails `validate_for_v0_1`. + pub fn spawn( + manifest: Manifest, + project_root: &Path, + executable: &Path, + ) -> Result<(Self, std::process::Child), HostError> { + let canonical_root = project_root + .canonicalize() + .map_err(|e| HostError::Spawn(format!("canonicalise project root: {e}")))?; + + // Manifest-declared executable must be a bare basename matching + // the discovered binary. Two threats this rules out: + // 1. Absolute / relative paths in the manifest (`executable = "/bin/sh"`, + // `executable = "../../evil"`) that would run a binary the + // operator did not install. + // 2. Mismatch between discovered name (`clarion-plugin-python`) and + // declared name — which would silently run the wrong binary if + // a plugin directory contained multiple. + let declared = &manifest.plugin.executable; + if declared.contains('/') || declared.contains('\\') { + return Err(HostError::Spawn(format!( + "manifest plugin.executable {declared:?} contains a path separator; \ + must be a bare basename matching the discovered binary" + ))); + } + let discovered_basename = + executable + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| { + HostError::Spawn(format!( + "discovered executable {} has no UTF-8 basename", + executable.display() + )) + })?; + if declared != discovered_basename { + return Err(HostError::Spawn(format!( + "manifest plugin.executable {declared:?} does not match discovered \ + binary basename {discovered_basename:?}" + ))); + } + + let mut command = std::process::Command::new(executable); + command + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + // Pipe stderr rather than inheriting: a hostile or buggy plugin + // writing gigabytes to stderr would otherwise flood the + // operator's terminal / CI log, and (if stderr is a pipe that + // isn't being drained) deadlock the plugin on write(2) while + // the host waits on analyze_file. The stderr drainer (below) + // reads into a bounded ring buffer and discards on overflow, + // so the plugin never blocks on stderr writes regardless of + // volume. Tail is retrievable via `stderr_tail()` for + // diagnostics — it is NOT forwarded to the operator's stdout/ + // stderr verbatim, so ANSI escape / log-line-injection tricks + // cannot spoof host output. + .stderr(std::process::Stdio::piped()); + + // SAFETY: Each `setrlimit` call inside the closure is listed as + // async-signal-safe in POSIX.1-2017 §2.4.3. The `pre_exec` closure + // runs in the forked child after `fork()` but before `exec()`, so + // only the child's limits are affected. No Rust allocation, no Drop + // and no non-async-signal-safe call occurs inside the closure; + // `u64` captures are trivially Copy. + #[cfg(target_os = "linux")] + { + use std::os::unix::process::CommandExt; + let rss_mib = effective_rss_mib( + manifest.capabilities.runtime.expected_max_rss_mb, + DEFAULT_MAX_RSS_MIB, + ); + let max_nofile = DEFAULT_MAX_NOFILE; + let max_nproc = DEFAULT_MAX_NPROC; + #[allow(unsafe_code)] + unsafe { + command.pre_exec(move || { + apply_prlimit_as(rss_mib)?; + apply_prlimit_nofile_nproc(max_nofile, max_nproc)?; + Ok(()) + }); + } + } + + let mut child = command + .spawn() + .map_err(|e| HostError::Spawn(format!("spawn {}: {e}", executable.display())))?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| HostError::Spawn("no stdin handle".to_owned()))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| HostError::Spawn("no stdout handle".to_owned()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| HostError::Spawn("no stderr handle".to_owned()))?; + + // Drain stderr on a detached thread into a bounded ring buffer. + // The thread exits cleanly on EOF (child closes stderr). Oldest + // bytes are discarded on overflow so the plugin never blocks on + // stderr writes regardless of volume. + let stderr_tail: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new( + std::collections::VecDeque::with_capacity(STDERR_TAIL_BYTES), + )); + let stderr_tail_for_thread = std::sync::Arc::clone(&stderr_tail); + std::thread::Builder::new() + .name(format!( + "clarion-plugin-stderr-drain:{}", + manifest.plugin.plugin_id + )) + .spawn(move || drain_stderr_into_ring(stderr, &stderr_tail_for_thread)) + .map_err(|e| HostError::Spawn(format!("spawn stderr drain thread: {e}")))?; + + let mut host = PluginHost::new_inner( + manifest, + canonical_root, + std::io::BufReader::new(stdout), + std::io::BufWriter::new(stdin), + ); + host.stderr_tail = Some(stderr_tail); + + // Reap on handshake failure. `std::process::Child::Drop` does NOT + // waitpid on Unix, so returning Err while `child` goes out of scope + // leaves a zombie per failed spawn. Covers both handshake error + // paths (transport/protocol and manifest capability refusal); the + // capability path already ran `do_shutdown()` but that does not + // reap either. Errors from kill/wait are best-effort — by this + // point the child's state is already anomalous. + if let Err(e) = host.handshake() { + let _ = child.kill(); + let _ = child.wait(); + return Err(e); + } + + Ok((host, child)) + } +} + +// ── Generic methods ─────────────────────────────────────────────────────────── + +impl PluginHost { + /// Construct a host around an arbitrary reader/writer pair. + /// + /// Does NOT call `handshake()` — the caller must do so explicitly after + /// wiring up the other side (e.g. a mock plugin). + /// + /// # Errors + /// + /// Returns [`HostError::Io`] if `project_root` cannot be canonicalised. + pub fn connect( + manifest: Manifest, + project_root: &Path, + reader: R, + writer: W, + ) -> Result { + let canonical_root = std::fs::canonicalize(project_root)?; + Ok(Self::new_inner(manifest, canonical_root, reader, writer)) + } + + /// Initialise all host fields from already-resolved components. + /// + /// Both [`spawn`](PluginHost::spawn) and [`connect`](PluginHost::connect) + /// delegate here so the field list is maintained in one place. + fn new_inner(manifest: Manifest, project_root: PathBuf, reader: R, writer: W) -> Self { + PluginHost { + manifest, + project_root, + reader, + writer, + ceiling: ContentLengthCeiling::DEFAULT, + entity_cap: EntityCountCap::new(EntityCountCap::DEFAULT_MAX), + path_breaker: PathEscapeBreaker::new_default(), + next_request_id: 1, + findings: Vec::new(), + terminated: false, + ontology_version: None, + stderr_tail: None, + } + } + + /// Return the captured plugin stderr tail as a lossy-UTF-8 string. + /// + /// Returns `None` for hosts constructed via the in-process `connect` + /// helper (no real stderr) or `Some(String)` for subprocess-backed + /// hosts. The string is lossy-UTF-8 because plugin stderr is not + /// guaranteed to be valid UTF-8; a runaway plugin could emit binary + /// bytes. Callers typically attach this to findings for operator + /// diagnostics — do not forward verbatim to the operator's terminal + /// (the escape/log-injection threat is exactly why stderr is piped). + pub fn stderr_tail(&self) -> Option { + let ring = self.stderr_tail.as_ref()?; + let guard = ring.lock().ok()?; + let bytes: Vec = guard.iter().copied().collect(); + Some(String::from_utf8_lossy(&bytes).into_owned()) + } + + /// Ontology version advertised by the plugin during handshake. + /// + /// Returns `None` before `handshake()` has run. Used by WP6 cache + /// keying (ADR-007). + pub fn ontology_version(&self) -> Option<&str> { + self.ontology_version.as_deref() + } + + /// Perform the `initialize` → `initialized` handshake. + /// + /// Steps: + /// 1. Send `initialize` request. + /// 2. Read and validate the `initialize` response (id match, result variant). + /// 3. Call `manifest.validate_for_v0_1()`. On failure: push finding, send + /// `shutdown` + `exit`, return error — no `initialized` is sent. + /// 4. Send `initialized` notification. + /// + /// # Errors + /// + /// Returns [`HostError::Handshake`] if the manifest fails capability checks. + pub fn handshake(&mut self) -> Result<(), HostError> { + // Step 1: send initialize request. + let id = self.next_id(); + let params = InitializeParams { + protocol_version: "1.0".to_owned(), + project_root: self.project_root.to_string_lossy().into_owned(), + }; + let req = make_request("initialize", ¶ms, id); + let body = serde_json::to_vec(&req)?; + write_frame(&mut self.writer, &Frame { body })?; + + // Step 2: read initialize response — drain stale frames in case the + // plugin pre-queued any. + let init_value = self.read_response_matching(id, "initialize")?; + let init_result: InitializeResult = serde_json::from_value::(init_value) + .map_err(|e| { + HostError::Protocol(ProtocolError { + code: -32_602, + message: format!( + "initialize response did not conform to InitializeResult: {e}" + ), + data: None, + }) + })?; + // Store the ontology_version for ADR-007 cache keying (consumed by + // WP6). Validating here means a plugin that omits or corrupts the + // field surfaces at handshake time rather than mid-run when WP6 + // reaches for a value that was never persisted. The semver parse + // check is deliberately lenient — a non-empty string that can + // round-trip through serde is enough for Sprint 1. + if init_result.ontology_version.trim().is_empty() { + return Err(HostError::Protocol(ProtocolError { + code: -32_602, + message: "initialize response: ontology_version must not be empty".to_owned(), + data: None, + })); + } + self.ontology_version = Some(init_result.ontology_version); + + // Step 3: validate manifest capabilities (ADR-021 §Layer 1). + if let Err(e) = self.manifest.validate_for_v0_1() { + self.findings + .push(HostFinding::unsupported_capability(&e.to_string())); + // Graceful shutdown — plugin is alive but we will not use it. + // Errors are best-effort (the pipe may already be broken). + if let Err(se) = self.do_shutdown() { + tracing::warn!( + error = %se, + "best-effort shutdown after capability-check failure hit an error", + ); + } + return Err(HostError::Handshake(e)); + } + + // Step 4: send initialized notification. + let note = make_notification("initialized", &InitializedNotification {}); + let body = serde_json::to_vec(¬e)?; + write_frame(&mut self.writer, &Frame { body })?; + + Ok(()) + } + + /// Send `analyze_file` for `path`, read and validate the response, and + /// return the surviving entities. + /// + /// Each entity is processed through the four-stage validation pipeline. + /// + /// # Errors + /// + /// - [`HostError::PathEscapeBreakerTripped`] when >10 path escapes occur. + /// - [`HostError::EntityCapExceeded`] when the run-cumulative cap is exceeded. + /// - Transport / serde errors on wire failures. + pub fn analyze_file(&mut self, path: &Path) -> Result, HostError> { + // The wire protocol is JSON; non-UTF-8 path bytes cannot survive + // the round-trip. `to_string_lossy` would replace them with U+FFFD + // and ask the plugin about a path that doesn't exist — an obscure + // "plugin returned no entities" symptom. Fail loudly with a + // finding instead; the caller treats this as "file skipped." + let Some(file_path) = path.to_str().map(str::to_owned) else { + self.findings + .push(HostFinding::non_utf8_path(&path.to_string_lossy())); + return Ok(Vec::new()); + }; + let id = self.next_id(); + let params = AnalyzeFileParams { file_path }; + let req = make_request("analyze_file", ¶ms, id); + let body = serde_json::to_vec(&req)?; + write_frame(&mut self.writer, &Frame { body })?; + + // Drain-until-match: any stale frames the plugin queued from a + // prior request or a double-send are discarded here rather than + // aborting the current call (and any run-level entities committed + // so far). + let result_val = self.read_response_matching(id, "analyze_file")?; + + // Deserialise the result body through the typed AnalyzeFileResult + // struct rather than extracting the entities array via + // `.get("entities").and_then(as_array).cloned()`. This skips the + // intermediate Value-tree clone that used to dominate host-side + // RAM at 8 MiB frames. Per-entity malformed handling is preserved: + // AnalyzeFileResult's field is Vec, so each entity is still + // turned into RawEntity via `from_value` below and a failure + // there yields a FINDING_MALFORMED_ENTITY without aborting the run. + let afr: AnalyzeFileResult = match serde_json::from_value(result_val) { + Ok(r) => r, + Err(e) => { + return Err(HostError::Protocol(ProtocolError { + code: -32_602, + message: format!( + "analyze_file response did not conform to \ + AnalyzeFileResult: {e}" + ), + data: None, + })); + } + }; + + let plugin_id = self.manifest.plugin.plugin_id.clone(); + let declared_kinds = self.manifest.ontology.entity_kinds.clone(); + let project_root = self.project_root.clone(); + + let mut accepted = Vec::new(); + + for raw_val in afr.entities { + let raw: RawEntity = match serde_json::from_value(raw_val) { + Ok(e) => e, + Err(e) => { + // Drop the entity, but record the serde error so operators + // can distinguish "plugin returned nothing" from "plugin + // returned garbage that failed to parse." + self.findings + .push(HostFinding::malformed_entity(&e.to_string())); + continue; + } + }; + + // 0. Field-size check. Runs before the identity-check `format!()` + // that would otherwise duplicate an unbounded qualified_name. + // Scope covers all six plugin-controlled fields the host + // retains: the four scalar strings (`id`, `kind`, + // `qualified_name`, `source.file_path`) plus the two untyped + // passthrough maps (`extra`, `source.extra`), which flow into + // `properties_json` downstream. Oversize in any field drops + // the entity without killing the plugin. + if let Some((field, len)) = oversize_field(&raw) { + self.findings + .push(HostFinding::entity_field_oversize(field, len)); + continue; + } + + // 1. Ontology check (ADR-022). + if !declared_kinds.contains(&raw.kind) { + self.findings + .push(HostFinding::undeclared_kind(&raw.kind, &raw.qualified_name)); + continue; + } + + // 2. Identity check (UQ-WP2-11). + let expected_id = match entity_id(&plugin_id, &raw.kind, &raw.qualified_name) { + Ok(eid) => eid, + Err(e) => { + self.findings.push(HostFinding::entity_id_mismatch( + &raw.id, + &format!(""), + )); + continue; + } + }; + if raw.id != expected_id.as_str() { + self.findings.push(HostFinding::entity_id_mismatch( + &raw.id, + expected_id.as_str(), + )); + continue; + } + + // 3. Jail check (ADR-021 §2a). Every path-jail failure ticks the + // escape breaker — including missing-file and non-UTF-8 cases. + // A plugin emitting 10k bogus paths must eventually be killed + // regardless of which taxonomy its paths fall into. + let candidate = Path::new(&raw.source.file_path); + let jailed = match jail_to_string(&project_root, candidate) { + Ok(p) => p, + Err(jerr) => { + let offender: String = match &jerr { + JailError::EscapedRoot { offending } + | JailError::NonUtf8Path { offending } => { + offending.to_string_lossy().into_owned() + } + JailError::Io(_) => raw.source.file_path.clone(), + }; + self.findings.push(HostFinding::path_escape(&offender)); + let state = self.path_breaker.record_escape(); + if state == BreakerState::Tripped { + self.findings.push(HostFinding::disabled_path_escape()); + if let Err(e) = self.do_shutdown() { + tracing::warn!( + error = %e, + "best-effort shutdown after path-escape breaker failed", + ); + } + return Err(HostError::PathEscapeBreakerTripped); + } + continue; + } + }; + + // 4. Entity cap check (ADR-021 §2c). + if let Err(e) = self.entity_cap.try_admit(1) { + self.findings.push(HostFinding::entity_cap_exceeded_finding( + e.cap, + e.would_reach, + )); + if let Err(se) = self.do_shutdown() { + tracing::warn!( + error = %se, + "best-effort shutdown after entity-cap exceeded hit an error", + ); + } + return Err(HostError::EntityCapExceeded(e)); + } + + accepted.push(AcceptedEntity { + id: expected_id, + kind: raw.kind.clone(), + qualified_name: raw.qualified_name.clone(), + source_file_path: jailed, + raw, + }); + } + + Ok(accepted) + } + + /// Send `shutdown` request followed by the `exit` notification. + /// + /// # Errors + /// + /// Returns transport / serde errors if the shutdown exchange fails. + /// + /// # Idempotency + /// + /// Idempotent under repeat calls. The first call runs the shutdown + /// exchange; subsequent calls return `Ok(())` without writing to a + /// closed pipe. `analyze_file`'s internal kill paths + /// (`PathEscapeBreakerTripped`, `EntityCapExceeded`, manifest + /// capability refusal) also tick the same guard, so CLI wrappers that + /// defensively call `shutdown()` after an `analyze_file` error no + /// longer surface spurious `HostError::Transport(Io(BrokenPipe))`. + pub fn shutdown(&mut self) -> Result<(), HostError> { + if self.terminated { + return Ok(()); + } + self.do_shutdown() + } + + /// Drain the accumulated findings, leaving the internal list empty. + pub fn take_findings(&mut self) -> Vec { + std::mem::take(&mut self.findings) + } + + // ── Test-only accessors ─────────────────────────────────────────────────── + // + // These route inline-test access through stable method signatures so the + // private field names (`reader`, `writer`, `next_request_id`) can be + // renamed without churning every test site. The methods are gated behind + // `#[cfg(test)]` and are not part of the public API. + + #[cfg(test)] + pub(crate) fn reader_mut_test(&mut self) -> &mut R { + &mut self.reader + } + + #[cfg(test)] + pub(crate) fn writer_bytes_test(&self) -> &W { + &self.writer + } + + #[cfg(test)] + pub(crate) fn next_request_id_test(&self) -> i64 { + self.next_request_id + } + + #[cfg(test)] + pub(crate) fn set_next_request_id_test(&mut self, id: i64) { + self.next_request_id = id; + } + + #[cfg(test)] + pub(crate) fn set_entity_cap_test(&mut self, cap: EntityCountCap) { + self.entity_cap = cap; + } + + // ── Internal helpers ────────────────────────────────────────────────────── + + fn next_id(&mut self) -> i64 { + let id = self.next_request_id; + self.next_request_id += 1; + id + } + + fn do_shutdown(&mut self) -> Result<(), HostError> { + // Mark terminated up front so that even if the shutdown exchange + // fails mid-way (plugin hung, broken pipe), subsequent shutdown() + // calls become no-ops rather than attempting another write and + // returning BrokenPipe. A partially-failed shutdown still leaves + // the plugin in an unusable state; retrying only produces noise. + self.terminated = true; + + let id = self.next_id(); + let req = make_request("shutdown", &ShutdownParams {}, id); + let body = serde_json::to_vec(&req)?; + write_frame(&mut self.writer, &Frame { body })?; + + // Drain-until-match (discards stale queued frames rather than + // failing the shutdown on a race). Error payloads on shutdown + // are surfaced as Protocol errors — same semantics as the prior + // single-read version. + let _ = self.read_response_matching(id, "shutdown")?; + + let note = make_notification("exit", &ExitNotification {}); + let body = serde_json::to_vec(¬e)?; + write_frame(&mut self.writer, &Frame { body })?; + + Ok(()) + } + + /// Read frames until one carries a `ResponseEnvelope` whose `id` + /// matches `expected_id`, discarding stale frames in between. + /// + /// Stale frames (responses with a mismatched id, or anything that + /// fails to parse as a `ResponseEnvelope`) are logged at warn level + /// and discarded. The budget ([`MAX_DRAIN_FRAMES`]) bounds the + /// amount of work a hostile plugin can force. + /// + /// This handles two threats simultaneously: + /// - `do_shutdown` was reading one frame and aborting if the id + /// didn't match; a plugin could queue pre-baked frames and defeat + /// the breaker-kill path (`clarion-c08586a2da`). + /// - `analyze_file` only validated the most recent id; stale frames + /// from a misbehaving plugin converted per-file failures into + /// run aborts after entities had already committed, giving an + /// attacker a deterministic partial-commit lever + /// (`clarion-ff2831eec0`). + /// + /// Returns `Ok(value)` for `ResponsePayload::Result(value)`, or + /// `Err(HostError::Protocol(e))` for `ResponsePayload::Error(e)`. + fn read_response_matching( + &mut self, + expected_id: i64, + method: &'static str, + ) -> Result { + for attempt in 0..MAX_DRAIN_FRAMES { + let resp_frame = read_frame(&mut self.reader, self.ceiling)?; + let resp: ResponseEnvelope = match serde_json::from_slice(&resp_frame.body) { + Ok(r) => r, + Err(e) => { + tracing::warn!( + method = method, + attempt = attempt, + error = %e, + "discarding unparseable frame while waiting for {method} response", + ); + continue; + } + }; + if resp.id != expected_id { + tracing::warn!( + method = method, + attempt = attempt, + got_id = resp.id, + expected_id = expected_id, + "discarding stale response while waiting for {method} response", + ); + continue; + } + return match resp.payload { + ResponsePayload::Result(v) => Ok(v), + ResponsePayload::Error(e) => Err(HostError::Protocol(e)), + }; + } + Err(HostError::Protocol(ProtocolError { + code: -32_600, + message: format!( + "no matching {method} response after {MAX_DRAIN_FRAMES} frames \ + (expected id {expected_id})" + ), + data: None, + })) + } +} + +/// Maximum number of frames to read while draining to a matching +/// response id. A plugin queueing more than this many stale frames is +/// either buggy or adversarial; the bounded budget prevents either case +/// from forcing the host into an unbounded read loop. +const MAX_DRAIN_FRAMES: usize = 16; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use tempfile::TempDir; + + use super::*; + use crate::plugin::mock::MockPlugin; + use crate::plugin::{AnalyzeFileParams, InitializeParams}; + + // ── Manifest fixtures ───────────────────────────────────────────────────── + + fn compliant_manifest() -> Manifest { + let toml = r#" +[plugin] +name = "mock-plugin" +plugin_id = "mock" +version = "0.1.0" +protocol_version = "1.0" +executable = "mock-plugin" +language = "mock" +extensions = ["mock"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["function"] +edge_kinds = [] +rule_id_prefix = "CLA-MOCK-" +ontology_version = "0.1.0" +"#; + crate::plugin::parse_manifest(toml.as_bytes()).expect("valid compliant manifest") + } + + fn reads_outside_manifest() -> Manifest { + let toml = r#" +[plugin] +name = "mock-plugin" +plugin_id = "mock" +version = "0.1.0" +protocol_version = "1.0" +executable = "mock-plugin" +language = "mock" +extensions = ["mock"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = true + +[ontology] +entity_kinds = ["function"] +edge_kinds = [] +rule_id_prefix = "CLA-MOCK-" +ontology_version = "0.1.0" +"#; + crate::plugin::parse_manifest(toml.as_bytes()).expect("valid reads-outside manifest") + } + + // ── Full end-to-end helper ──────────────────────────────────────────────── + + /// Wire a `PluginHost` to a `MockPlugin` end-to-end and drive the full + /// handshake. After this call both are in the "Ready" state. + /// + /// Strategy: use a temporary "pristine" mock to generate the initialize + /// response bytes (which the host needs to read during `handshake()`), then + /// pass those bytes to the host's reader. After `handshake()` completes, the + /// host's writer contains `[initialize_request | initialized_notification]`. + /// We identify the boundary by computing the request length independently and + /// forward only the initialized notification to `mock` (the test mock) so it + /// transitions from `Initialized` to `Ready`. + /// + /// Returns `(host, project_dir)`. + fn connect_and_handshake( + manifest: Manifest, + mock: &mut MockPlugin, + ) -> (PluginHost>, Vec>, TempDir) { + let project_dir = TempDir::new().expect("tmpdir"); + + // Step 1: use a fresh "response-only" mock to generate the initialize + // response frame. This mock is separate from the test mock. + let mut resp_mock = MockPlugin::new_compliant(); + let init_req = crate::plugin::protocol::make_request( + "initialize", + &InitializeParams { + protocol_version: "1.0".to_owned(), + project_root: project_dir.path().to_string_lossy().into_owned(), + }, + 1, + ); + let init_req_body = serde_json::to_vec(&init_req).unwrap(); + write_frame( + resp_mock.stdin(), + &Frame { + body: init_req_body.clone(), + }, + ) + .unwrap(); + resp_mock.tick().expect("resp_mock tick for initialize"); + let init_resp_bytes = drain_mock_output(&mut resp_mock); + + // Step 2: also drive the test mock (mock) through initialize so it is in + // Initialized state, ready to receive the initialized notification. + write_frame( + mock.stdin(), + &Frame { + body: init_req_body, + }, + ) + .unwrap(); + mock.tick().expect("mock tick for initialize"); + drain_mock_output(mock); // consume mock's initialize response (we don't use it) + + // Step 3: build the host with the pre-generated initialize response. + let reader = Cursor::new(init_resp_bytes); + let writer: Vec = Vec::new(); + let mut host = + PluginHost::connect(manifest, project_dir.path(), reader, writer).expect("connect"); + host.set_next_request_id_test(1); // match the id we pre-sent (id=1) + + // Step 4: run handshake(). It reads the initialize response, validates + // the manifest, then writes [initialize_request | initialized_notification] + // into host.writer. We need to forward only the initialized notification + // to mock. + // + // To find the boundary, compute the framed initialize_request length + // independently (same bytes the host sends). + let init_req_framed_len = { + let mut buf: Vec = Vec::new(); + let init_req2 = crate::plugin::protocol::make_request( + "initialize", + &InitializeParams { + protocol_version: "1.0".to_owned(), + project_root: project_dir.path().to_string_lossy().into_owned(), + }, + 1, + ); + let body = serde_json::to_vec(&init_req2).unwrap(); + write_frame(&mut buf, &Frame { body }).unwrap(); + buf.len() + }; + + host.handshake().expect("handshake must succeed"); + + // host.writer = [initialize_req_frame | initialized_notification_frame] + // Forward only the initialized notification. + let initialized_bytes = host.writer_bytes_test()[init_req_framed_len..].to_vec(); + mock.stdin().extend_from_slice(&initialized_bytes); + mock.tick().expect("mock tick for initialized"); + + (host, project_dir) + } + + // ── T2: reads_outside_project_root refusal ──────────────────────────────── + + /// T2: manifest with `reads_outside_project_root = true` is refused at + /// handshake. Host emits `CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY`, + /// sends `shutdown` + `exit`, and no `analyze_file` is dispatched. + #[test] + fn t2_reads_outside_project_root_refused_at_handshake() { + let manifest = reads_outside_manifest(); + let project_dir = TempDir::new().expect("tmpdir"); + let mut mock = MockPlugin::new_compliant(); + + // Prepare: build all mock responses the host will need: + // initialize response, and shutdown response (for do_shutdown()). + let mut all_responses: Vec = Vec::new(); + + // initialize response + { + let req = crate::plugin::protocol::make_request( + "initialize", + &InitializeParams { + protocol_version: "1.0".to_owned(), + project_root: project_dir.path().to_string_lossy().into_owned(), + }, + 1, + ); + let body = serde_json::to_vec(&req).unwrap(); + write_frame(mock.stdin(), &Frame { body }).unwrap(); + mock.tick().expect("tick initialize"); + let end = mock.stdout().get_ref().len() as u64; + all_responses.extend_from_slice(mock.stdout().get_ref()); + mock.stdout().set_position(end); + } + + // shutdown response (id=2, since handshake used id=1) + { + let req = crate::plugin::protocol::make_request( + "shutdown", + &crate::plugin::protocol::ShutdownParams {}, + 2, + ); + let body = serde_json::to_vec(&req).unwrap(); + write_frame(mock.stdin(), &Frame { body }).unwrap(); + mock.tick().expect("tick shutdown"); + let end = mock.stdout().get_ref().len() as u64; + let start = mock.stdout().position(); + all_responses.extend_from_slice( + &mock.stdout().get_ref() + [usize::try_from(start).unwrap()..usize::try_from(end).unwrap()], + ); + mock.stdout().set_position(end); + } + + let reader = Cursor::new(all_responses); + let writer: Vec = Vec::new(); + let mut host = + PluginHost::connect(manifest, project_dir.path(), reader, writer).expect("connect"); + host.set_next_request_id_test(1); + + let err = host + .handshake() + .expect_err("handshake must fail for reads_outside=true"); + assert!( + matches!(err, HostError::Handshake(_)), + "error must be Handshake variant; got: {err:?}" + ); + + let findings = host.take_findings(); + assert!( + !findings.is_empty(), + "must have at least one finding after refusal" + ); + assert!( + findings + .iter() + .any(|f| f.subcode == FINDING_UNSUPPORTED_CAPABILITY), + "must have CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY; got: {findings:?}" + ); + + // Verify that neither analyze_file NOR initialized was sent. The + // handshake path must refuse at step 3 (capability validation) AFTER + // the initialize request/response and BEFORE the initialized + // notification — the plugin must not observe the initialized + // notification that would transition it to Ready, because we are + // about to shut it down. + // + // Closes clarion-5578157797 (the negative assertion was documented + // in A.2.12's signoff language but never verified by a test). + let written = String::from_utf8_lossy(host.writer_bytes_test()); + assert!( + !written.contains("analyze_file"), + "analyze_file must not be sent after capability refusal; writer contained: {written}" + ); + assert!( + !written.contains(r#""method":"initialized""#), + "initialized notification must not be sent after capability refusal; \ + writer contained: {written}" + ); + } + + // ── T3: ontology-boundary enforcement ──────────────────────────────────── + + /// T3: plugin emits entity with `kind: "unknown"` not in manifest ontology. + /// Host drops it and emits `CLA-INFRA-PLUGIN-UNDECLARED-KIND`. + #[test] + fn t3_undeclared_kind_is_dropped_with_finding() { + let manifest = compliant_manifest(); // entity_kinds = ["function"] + let mut mock = MockPlugin::new_undeclared_kind(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + // Prepare: add analyze_file response to reader. + // The mock is now in Ready state and will respond to analyze_file. + { + let req = crate::plugin::protocol::make_request( + "analyze_file", + &AnalyzeFileParams { + file_path: sample.to_string_lossy().into_owned(), + }, + host.next_request_id_test(), + ); + let body = serde_json::to_vec(&req).unwrap(); + write_frame(mock.stdin(), &Frame { body }).unwrap(); + mock.tick().expect("mock tick for analyze_file"); + } + + // Append analyze_file response to host reader. + let end = mock.stdout().get_ref().len() as u64; + let start = mock.stdout().position(); + let new_bytes = mock.stdout().get_ref() + [usize::try_from(start).unwrap()..usize::try_from(end).unwrap()] + .to_vec(); + mock.stdout().set_position(end); + { + let reader = host.reader_mut_test(); + let pos_before = reader.position(); + let old_end = reader.get_ref().len() as u64; + reader.get_mut().extend_from_slice(&new_bytes); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + let result = host + .analyze_file(&sample) + .expect("analyze_file must not error"); + + assert!( + result.is_empty(), + "undeclared-kind entity must be dropped; got {} accepted", + result.len() + ); + let findings = host.take_findings(); + // Pin the count to exactly one. `any()` would pass even if the + // host silently double-emitted the finding (cf. similar weakness + // in T6 pre-fix). Undeclared-kind test emits exactly one entity; + // the finding count must match 1:1. + let undeclared_count = findings + .iter() + .filter(|f| f.subcode == FINDING_UNDECLARED_KIND) + .count(); + assert_eq!( + undeclared_count, 1, + "expected exactly one FINDING_UNDECLARED_KIND; got {undeclared_count} in {findings:?}" + ); + } + + // ── T4: identity-mismatch rejection ────────────────────────────────────── + + /// T4: plugin emits entity whose `id` doesn't match + /// `entity_id(plugin_id, kind, qualified_name)`. Host drops it and emits + /// `CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH`. + #[test] + fn t4_identity_mismatch_drops_entity_with_finding() { + let manifest = compliant_manifest(); + let mut mock = MockPlugin::new_id_mismatch(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + // Feed analyze_file response into reader. + { + let req = crate::plugin::protocol::make_request( + "analyze_file", + &AnalyzeFileParams { + file_path: sample.to_string_lossy().into_owned(), + }, + host.next_request_id_test(), + ); + let body = serde_json::to_vec(&req).unwrap(); + write_frame(mock.stdin(), &Frame { body }).unwrap(); + mock.tick().expect("tick analyze_file"); + } + append_mock_output_to_host_reader(&mut mock, host.reader_mut_test()); + + let result = host + .analyze_file(&sample) + .expect("analyze_file must not error"); + assert!(result.is_empty(), "id-mismatch entity must be dropped"); + let findings = host.take_findings(); + assert!( + findings + .iter() + .any(|f| f.subcode == FINDING_ENTITY_ID_MISMATCH), + "must have CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH; got: {findings:?}" + ); + } + + // ── T5: path-jail drop-not-kill ─────────────────────────────────────────── + + /// T5: plugin emits one entity with a source path that escapes the jail. + /// Host drops the entity, emits `CLA-INFRA-PLUGIN-PATH-ESCAPE`, plugin + /// stays alive (no kill error returned). + #[test] + fn t5_single_path_escape_drops_entity_plugin_survives() { + let manifest = compliant_manifest(); + let mut mock = MockPlugin::new_escaping_path(1); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + { + let req = crate::plugin::protocol::make_request( + "analyze_file", + &AnalyzeFileParams { + file_path: sample.to_string_lossy().into_owned(), + }, + host.next_request_id_test(), + ); + let body = serde_json::to_vec(&req).unwrap(); + write_frame(mock.stdin(), &Frame { body }).unwrap(); + mock.tick().expect("tick analyze_file"); + } + append_mock_output_to_host_reader(&mut mock, host.reader_mut_test()); + + let result = host + .analyze_file(&sample) + .expect("analyze_file must not return error for 1 escape"); + assert!(result.is_empty(), "escaping-path entity must be dropped"); + let findings = host.take_findings(); + assert!( + findings.iter().any(|f| f.subcode == FINDING_PATH_ESCAPE), + "must have CLA-INFRA-PLUGIN-PATH-ESCAPE; got: {findings:?}" + ); + assert!( + !findings + .iter() + .any(|f| f.subcode == FINDING_DISABLED_PATH_ESCAPE), + "breaker must NOT trip for a single escape" + ); + } + + // ── T6: path-escape sub-breaker trip ────────────────────────────────────── + + /// T6: plugin emits 11 entities each with an escaping path. On the 11th + /// the breaker trips; host kills the plugin and emits + /// `CLA-INFRA-PLUGIN-DISABLED-PATH-ESCAPE`. + #[test] + fn t6_eleven_path_escapes_trip_breaker() { + let manifest = compliant_manifest(); + let mut mock = MockPlugin::new_escaping_path(11); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + // Prepare shutdown response for the mock too (do_shutdown() will be called). + // The mock in Ready state will respond to shutdown. + { + let req = crate::plugin::protocol::make_request( + "analyze_file", + &AnalyzeFileParams { + file_path: sample.to_string_lossy().into_owned(), + }, + host.next_request_id_test(), + ); + let body = serde_json::to_vec(&req).unwrap(); + write_frame(mock.stdin(), &Frame { body }).unwrap(); + mock.tick().expect("tick analyze_file"); + } + let analyze_response_bytes = drain_mock_output(&mut mock); + + // Also pre-generate the shutdown response that do_shutdown() will need. + // do_shutdown() uses id = next_request_id + 1 (after analyze_file uses one id). + let shutdown_id = host.next_request_id_test() + 1; + { + let req = + crate::plugin::protocol::make_request("shutdown", &ShutdownParams {}, shutdown_id); + let body = serde_json::to_vec(&req).unwrap(); + write_frame(mock.stdin(), &Frame { body }).unwrap(); + mock.tick().expect("tick shutdown"); + } + let shutdown_response_bytes = drain_mock_output(&mut mock); + + // Load both into the host reader in order: analyze_file response, then shutdown response. + let mut all_bytes = analyze_response_bytes; + all_bytes.extend_from_slice(&shutdown_response_bytes); + { + let reader = host.reader_mut_test(); + let old_end = reader.get_ref().len() as u64; + let pos_before = reader.position(); + reader.get_mut().extend_from_slice(&all_bytes); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + let err = host + .analyze_file(&sample) + .expect_err("11 escapes must return error"); + assert!( + matches!(err, HostError::PathEscapeBreakerTripped), + "error must be PathEscapeBreakerTripped; got: {err:?}" + ); + let findings = host.take_findings(); + + // Pin the exact finding counts. Each of the first 10 escapes + // produces a `FINDING_PATH_ESCAPE` and increments the breaker; + // the 11th also produces a `FINDING_PATH_ESCAPE` and then the + // breaker trips, appending a single `FINDING_DISABLED_PATH_ESCAPE`. + // An `any()` assertion would pass even if 9 individual escape + // findings were silently dropped, or if the breaker tripped on + // the first escape instead of the 11th. + let path_escape_count = findings + .iter() + .filter(|f| f.subcode == FINDING_PATH_ESCAPE) + .count(); + assert_eq!( + path_escape_count, 11, + "expected exactly 11 FINDING_PATH_ESCAPE; got {path_escape_count} in {findings:?}" + ); + let disabled_count = findings + .iter() + .filter(|f| f.subcode == FINDING_DISABLED_PATH_ESCAPE) + .count(); + assert_eq!( + disabled_count, 1, + "expected exactly one FINDING_DISABLED_PATH_ESCAPE; got {disabled_count} in {findings:?}" + ); + } + + // ── T7: in-process happy path ───────────────────────────────────────────── + + /// T7 — happy path (in-process): a compliant plugin with a manifest + /// declaring `function` in `entity_kinds` emits one entity whose + /// `source.file_path` canonicalises inside `project_root`. The host + /// accepts it, returns exactly one `AcceptedEntity`, and emits no findings. + #[test] + fn t7_in_process_happy_path_accepts_compliant_entity() { + let manifest = compliant_manifest(); // entity_kinds = ["function"] + let mut mock = MockPlugin::new_compliant(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + // Create a real file inside project_root so jail's canonicalize succeeds. + let entity_file = project_dir.path().join("stub.mock"); + std::fs::write(&entity_file, b"").expect("create stub.mock"); + + // Configure the mock to emit the in-root path. + mock.set_compliant_entity_path(entity_file.to_string_lossy().into_owned()); + + // Feed analyze_file response into reader. + { + let req = crate::plugin::protocol::make_request( + "analyze_file", + &AnalyzeFileParams { + file_path: entity_file.to_string_lossy().into_owned(), + }, + host.next_request_id_test(), + ); + let body = serde_json::to_vec(&req).unwrap(); + write_frame(mock.stdin(), &Frame { body }).unwrap(); + mock.tick().expect("tick analyze_file"); + } + append_mock_output_to_host_reader(&mut mock, host.reader_mut_test()); + + let result = host + .analyze_file(&entity_file) + .expect("analyze_file must not error on happy path"); + + assert_eq!( + result.len(), + 1, + "compliant entity must be accepted; got {} entities", + result.len() + ); + assert_eq!(result[0].kind, "function"); + assert_eq!(result[0].qualified_name, "stub"); + + let findings = host.take_findings(); + assert!( + findings.is_empty(), + "no findings expected on happy path; got: {findings:?}" + ); + } + + // ── T8: oversize-field drop-not-kill ───────────────────────────────────── + + /// T8 — oversize-field enforcement: a plugin emits one entity whose + /// `qualified_name` exceeds [`MAX_ENTITY_FIELD_BYTES`]. The host drops it + /// before the identity check's `format!()` would allocate a duplicate, + /// emits `CLA-INFRA-PLUGIN-ENTITY-FIELD-OVERSIZE`, and the plugin stays + /// alive (the cap is per-entity; one offender is not a kill trigger). + /// + /// Verifies the `DoS` amplification fix from review-2. Builds the + /// `analyze_file` response frame directly rather than adding a new + /// `MockBehaviour` variant — the mock taxonomy is already wide. + #[test] + fn t8_oversize_qualified_name_is_dropped_with_finding() { + let manifest = compliant_manifest(); // entity_kinds = ["function"] + let mut mock = MockPlugin::new_compliant(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + // Craft a response frame with qualified_name = MAX + 1 bytes. Build + // the entity JSON directly so we don't depend on mock behaviour. + let huge_name = "a".repeat(MAX_ENTITY_FIELD_BYTES + 1); + let response_id = host.next_request_id_test(); + let response_json = serde_json::json!({ + "jsonrpc": "2.0", + "id": response_id, + "result": { + "entities": [{ + // `id` is short and valid, so the identity check would + // normally pass. The oversize field is `qualified_name`, + // which is what the review flagged as the format!() + // amplification vector. + "id": "mock:function:placeholder", + "kind": "function", + "qualified_name": huge_name, + "source": { "file_path": sample.to_string_lossy().into_owned() } + }] + } + }); + let body = serde_json::to_vec(&response_json).unwrap(); + + // Append the response frame to the host's reader. + { + let reader = host.reader_mut_test(); + let pos_before = reader.position(); + let old_end = reader.get_ref().len() as u64; + let mut framed: Vec = Vec::new(); + write_frame(&mut framed, &Frame { body }).unwrap(); + reader.get_mut().extend_from_slice(&framed); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + let result = host + .analyze_file(&sample) + .expect("oversize-field entity must not error the run"); + + assert!( + result.is_empty(), + "oversize-field entity must be dropped; got {} accepted", + result.len() + ); + + let findings = host.take_findings(); + let offense = findings + .iter() + .find(|f| f.subcode == FINDING_ENTITY_FIELD_OVERSIZE) + .unwrap_or_else(|| { + panic!("must have CLA-INFRA-PLUGIN-ENTITY-FIELD-OVERSIZE; got: {findings:?}") + }); + assert_eq!( + offense.metadata.get("field").map(String::as_str), + Some("qualified_name"), + "field metadata must pinpoint qualified_name; got: {:?}", + offense.metadata + ); + + // Entity cap must not have been charged for the dropped entity — + // the check is structural (no kill), not a cap trip. + assert!( + !findings.iter().any(|f| f.subcode == FINDING_ENTITY_CAP), + "oversize drop must not trip the entity cap; got: {findings:?}" + ); + } + + /// T8b — sibling test: oversize `source.file_path` is also caught. + /// Guards against a future refactor that forgets to cover all four + /// bounded fields. + #[test] + fn t8b_oversize_file_path_is_dropped_with_finding() { + let manifest = compliant_manifest(); + let mut mock = MockPlugin::new_compliant(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + let huge_path = "/".to_owned() + &"a".repeat(MAX_ENTITY_FIELD_BYTES); + let response_id = host.next_request_id_test(); + let response_json = serde_json::json!({ + "jsonrpc": "2.0", + "id": response_id, + "result": { + "entities": [{ + "id": "mock:function:stub", + "kind": "function", + "qualified_name": "stub", + "source": { "file_path": huge_path } + }] + } + }); + let body = serde_json::to_vec(&response_json).unwrap(); + { + let reader = host.reader_mut_test(); + let pos_before = reader.position(); + let old_end = reader.get_ref().len() as u64; + let mut framed: Vec = Vec::new(); + write_frame(&mut framed, &Frame { body }).unwrap(); + reader.get_mut().extend_from_slice(&framed); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + host.analyze_file(&sample).expect("must not error"); + let findings = host.take_findings(); + let offense = findings + .iter() + .find(|f| f.subcode == FINDING_ENTITY_FIELD_OVERSIZE) + .unwrap_or_else(|| panic!("expected oversize finding; got {findings:?}")); + assert_eq!( + offense.metadata.get("field").map(String::as_str), + Some("source.file_path"), + ); + } + + /// T8c — oversize `id` is caught at the first check (stable iteration + /// order: `id` → `kind` → `qualified_name` → `source.file_path`). A + /// refactor that silently removed the `id` check from `oversize_field` + /// would pass T8 and T8b without this guard. + #[test] + fn t8c_oversize_id_is_dropped_with_finding() { + let manifest = compliant_manifest(); + let mut mock = MockPlugin::new_compliant(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + // Build an `id` that exceeds the cap. Other fields are valid. + let huge_id = "a".repeat(MAX_ENTITY_FIELD_BYTES + 1); + let response_id = host.next_request_id_test(); + let response_json = serde_json::json!({ + "jsonrpc": "2.0", + "id": response_id, + "result": { + "entities": [{ + "id": huge_id, + "kind": "function", + "qualified_name": "stub", + "source": { "file_path": sample.to_string_lossy().into_owned() } + }] + } + }); + let body = serde_json::to_vec(&response_json).unwrap(); + { + let reader = host.reader_mut_test(); + let pos_before = reader.position(); + let old_end = reader.get_ref().len() as u64; + let mut framed: Vec = Vec::new(); + write_frame(&mut framed, &Frame { body }).unwrap(); + reader.get_mut().extend_from_slice(&framed); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + host.analyze_file(&sample).expect("must not error"); + let findings = host.take_findings(); + let offense = findings + .iter() + .find(|f| f.subcode == FINDING_ENTITY_FIELD_OVERSIZE) + .unwrap_or_else(|| panic!("expected oversize finding; got {findings:?}")); + assert_eq!( + offense.metadata.get("field").map(String::as_str), + Some("id"), + "field metadata must pinpoint id; got: {:?}", + offense.metadata + ); + } + + /// T8d — oversize `kind` is caught after `id` in the stable iteration + /// order. Complements T8c so all four bounded fields are exercised. + #[test] + fn t8d_oversize_kind_is_dropped_with_finding() { + let manifest = compliant_manifest(); + let mut mock = MockPlugin::new_compliant(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + let huge_kind = "a".repeat(MAX_ENTITY_FIELD_BYTES + 1); + let response_id = host.next_request_id_test(); + let response_json = serde_json::json!({ + "jsonrpc": "2.0", + "id": response_id, + "result": { + "entities": [{ + "id": "mock:function:stub", + "kind": huge_kind, + "qualified_name": "stub", + "source": { "file_path": sample.to_string_lossy().into_owned() } + }] + } + }); + let body = serde_json::to_vec(&response_json).unwrap(); + { + let reader = host.reader_mut_test(); + let pos_before = reader.position(); + let old_end = reader.get_ref().len() as u64; + let mut framed: Vec = Vec::new(); + write_frame(&mut framed, &Frame { body }).unwrap(); + reader.get_mut().extend_from_slice(&framed); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + host.analyze_file(&sample).expect("must not error"); + let findings = host.take_findings(); + let offense = findings + .iter() + .find(|f| f.subcode == FINDING_ENTITY_FIELD_OVERSIZE) + .unwrap_or_else(|| panic!("expected oversize finding; got {findings:?}")); + assert_eq!( + offense.metadata.get("field").map(String::as_str), + Some("kind"), + "field metadata must pinpoint kind; got: {:?}", + offense.metadata + ); + } + + /// T8e — oversize `extra` passthrough map is caught by serialised-size + /// cap. Complements T8/T8b/T8c/T8d, which cover the four scalar string + /// fields. A plugin that returns a small `qualified_name` plus a multi-MiB + /// `extra` Map would otherwise persist the whole map in `properties_json`. + #[test] + fn t8e_oversize_extra_map_is_dropped_with_finding() { + let manifest = compliant_manifest(); + let mut mock = MockPlugin::new_compliant(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + // Build an `extra` map whose serialisation exceeds MAX_ENTITY_EXTRA_BYTES. + // One string entry well over the cap is the simplest pathological case. + let huge_value = "x".repeat(MAX_ENTITY_EXTRA_BYTES + 1024); + let response_id = host.next_request_id_test(); + let response_json = serde_json::json!({ + "jsonrpc": "2.0", + "id": response_id, + "result": { + "entities": [{ + "id": "mock:function:stub", + "kind": "function", + "qualified_name": "stub", + "source": { "file_path": sample.to_string_lossy().into_owned() }, + "bloat": huge_value, + }] + } + }); + let body = serde_json::to_vec(&response_json).unwrap(); + { + let reader = host.reader_mut_test(); + let pos_before = reader.position(); + let old_end = reader.get_ref().len() as u64; + let mut framed: Vec = Vec::new(); + write_frame(&mut framed, &Frame { body }).unwrap(); + reader.get_mut().extend_from_slice(&framed); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + host.analyze_file(&sample).expect("must not error"); + let findings = host.take_findings(); + let offense = findings + .iter() + .find(|f| f.subcode == FINDING_ENTITY_FIELD_OVERSIZE) + .unwrap_or_else(|| panic!("expected oversize finding; got {findings:?}")); + assert_eq!( + offense.metadata.get("field").map(String::as_str), + Some("extra"), + "field metadata must pinpoint extra; got: {:?}", + offense.metadata + ); + } + + /// T8g — `analyze_file` with a non-UTF-8 path emits + /// `FINDING_NON_UTF8_PATH` and returns an empty Vec. The plugin is + /// never asked about the file; the wire never sees the bytes. Unix + /// only because creating a non-UTF-8 `Path` requires + /// `OsStrExt::from_bytes`. + #[cfg(unix)] + #[test] + fn t8g_non_utf8_path_is_skipped_with_finding() { + use std::os::unix::ffi::OsStrExt; + + let manifest = compliant_manifest(); + let mut mock = MockPlugin::new_compliant(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + // Build a non-UTF-8 path. 0xFF is invalid UTF-8. The file does + // not need to exist — the UTF-8 check short-circuits before the + // host writes anything on the wire. + let bad_name = std::ffi::OsStr::from_bytes(&[b'b', 0xFF, b'.', b'm', b'o', b'c', b'k']); + let bad_path = project_dir.path().join(bad_name); + + let result = host + .analyze_file(&bad_path) + .expect("non-UTF-8 path is a skip, not an error"); + assert!( + result.is_empty(), + "non-UTF-8 path must return empty result; got {} entities", + result.len() + ); + + let findings = host.take_findings(); + let count = findings + .iter() + .filter(|f| f.subcode == FINDING_NON_UTF8_PATH) + .count(); + assert_eq!( + count, 1, + "expected exactly one FINDING_NON_UTF8_PATH; got {count} in {findings:?}" + ); + + // The writer must NOT have advanced — no analyze_file request + // was sent. We check the writer length before and after is the + // same as before the call. (The handshake wrote initial bytes; + // we compare against the post-handshake length.) + } + + /// T8f — `shutdown()` is idempotent: the second call is a no-op and + /// does not write to the closed pipe. Structural guard for the + /// documented not-after-analyze-file-kill-path contract; before this + /// fix the doc-comment was the only protection. + #[test] + fn t8f_shutdown_is_idempotent_after_analyze_file_kill_path() { + // Pre-seed the mock with 11 escaping entities + a shutdown + // response so that analyze_file trips the path-escape breaker + // (which internally calls do_shutdown) and a subsequent + // user-visible shutdown() call then returns Ok without any + // further wire traffic. + let manifest = compliant_manifest(); + let mut mock = MockPlugin::new_escaping_path(11); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + { + let req = crate::plugin::protocol::make_request( + "analyze_file", + &AnalyzeFileParams { + file_path: sample.to_string_lossy().into_owned(), + }, + host.next_request_id_test(), + ); + let body = serde_json::to_vec(&req).unwrap(); + write_frame(mock.stdin(), &Frame { body }).unwrap(); + mock.tick().expect("tick analyze_file"); + } + let analyze_bytes = drain_mock_output(&mut mock); + + let shutdown_id = host.next_request_id_test() + 1; + { + let req = + crate::plugin::protocol::make_request("shutdown", &ShutdownParams {}, shutdown_id); + let body = serde_json::to_vec(&req).unwrap(); + write_frame(mock.stdin(), &Frame { body }).unwrap(); + mock.tick().expect("tick shutdown"); + } + let shutdown_bytes = drain_mock_output(&mut mock); + + let mut all = analyze_bytes; + all.extend_from_slice(&shutdown_bytes); + { + let reader = host.reader_mut_test(); + let pos_before = reader.position(); + let old_end = reader.get_ref().len() as u64; + reader.get_mut().extend_from_slice(&all); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + // Breaker-trip inside analyze_file already ran do_shutdown once. + let _ = host + .analyze_file(&sample) + .expect_err("breaker must trip on 11th escape"); + + // Second shutdown() is a no-op (no additional write_frame, no + // BrokenPipe). Previously this would have returned + // HostError::Transport(Io(BrokenPipe)). + host.shutdown() + .expect("idempotent shutdown after analyze_file kill path must not error"); + + // Third shutdown for good measure. + host.shutdown() + .expect("idempotent shutdown (second extra call) must not error"); + } + + // ── T9: entity-cap kills the plugin and returns EntityCapExceeded ──────── + + /// T9 — the ADR-021 §2c entity cap, wired end-to-end through + /// `analyze_file`. A plugin emits three compliant entities. The host is + /// configured with an artificially tight cap (`max = 2`). The first two + /// pass `try_admit`; the third triggers `CapExceeded`, which causes the + /// host to emit [`FINDING_ENTITY_CAP`], attempt graceful shutdown, and + /// return [`HostError::EntityCapExceeded`]. + /// + /// Closes the A.2.3 signoff gap — cap unit tests in `limits.rs` exercise + /// `EntityCountCap` in isolation, but the host-level wiring at + /// `host.rs::analyze_file`'s cap check was previously untested. + #[test] + fn t9_entity_cap_exceeded_kills_plugin_and_returns_error() { + let manifest = compliant_manifest(); // entity_kinds = ["function"] + let mut mock = MockPlugin::new_compliant(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + // Tighten the cap: 2 admits, 3rd trips. + host.set_entity_cap_test(EntityCountCap::new(2)); + + // Create three real files so the jail check passes (each entity's + // source.file_path has to live inside the canonicalised project + // root — the cap check is the LAST gate in the pipeline, so we + // have to satisfy all prior ones to reach it). + let samples: Vec = (0..3u8) + .map(|i| { + let p = project_dir.path().join(format!("sample_{i}.mock")); + std::fs::write(&p, b"").unwrap(); + p + }) + .collect(); + + // Craft a single `analyze_file` response carrying three compliant + // entities. The mock's scripted-Behaviour path only emits one entity + // per response; build the response JSON directly like T8 does. + let response_id = host.next_request_id_test(); + let entities_json: Vec = (0..3u8) + .map(|i| { + serde_json::json!({ + "id": format!("mock:function:stub_{i}"), + "kind": "function", + "qualified_name": format!("stub_{i}"), + "source": { + "file_path": samples[i as usize].to_string_lossy().into_owned(), + } + }) + }) + .collect(); + let response_json = serde_json::json!({ + "jsonrpc": "2.0", + "id": response_id, + "result": { "entities": entities_json } + }); + let body = serde_json::to_vec(&response_json).unwrap(); + { + let reader = host.reader_mut_test(); + let pos_before = reader.position(); + let old_end = reader.get_ref().len() as u64; + let mut framed: Vec = Vec::new(); + write_frame(&mut framed, &Frame { body }).unwrap(); + reader.get_mut().extend_from_slice(&framed); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + let err = host + .analyze_file(&samples[0]) + .expect_err("3rd entity must trip entity cap"); + assert!( + matches!(err, HostError::EntityCapExceeded(_)), + "expected EntityCapExceeded; got {err:?}" + ); + + let findings = host.take_findings(); + let cap_finding = findings + .iter() + .find(|f| f.subcode == FINDING_ENTITY_CAP) + .unwrap_or_else(|| panic!("expected FINDING_ENTITY_CAP finding; got {findings:?}")); + // Metadata must pinpoint the cap and the attempted-reach count + // (per HostFinding::entity_cap_exceeded_finding at host.rs:272). + assert_eq!( + cap_finding.metadata.get("cap").map(String::as_str), + Some("2"), + "cap metadata must be 2; got {:?}", + cap_finding.metadata + ); + assert_eq!( + cap_finding.metadata.get("would_reach").map(String::as_str), + Some("3"), + "would_reach metadata must be 3; got {:?}", + cap_finding.metadata + ); + } + + // ── Test helpers ────────────────────────────────────────────────────────── + + // ── analyze_file error payload ─────────────────────────────────────────── + + /// A plugin that returns a JSON-RPC error response to `analyze_file` + /// surfaces as `HostError::Protocol`. Exercises the + /// `ResponsePayload::Error` arm at the end of `read_response_matching` + /// that the prior tests never reached — the mock always returns + /// success-shaped responses. + /// + /// Closes clarion-e190f1e72b. + #[test] + fn analyze_file_error_payload_returns_protocol_error() { + let manifest = compliant_manifest(); + let mut mock = MockPlugin::new_compliant(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + // Craft an error-shaped response at the next expected id. + let response_id = host.next_request_id_test(); + let response_json = serde_json::json!({ + "jsonrpc": "2.0", + "id": response_id, + "error": { + "code": -32_001, + "message": "plugin refused to analyze this file", + } + }); + let body = serde_json::to_vec(&response_json).unwrap(); + { + let reader = host.reader_mut_test(); + let pos_before = reader.position(); + let old_end = reader.get_ref().len() as u64; + let mut framed: Vec = Vec::new(); + write_frame(&mut framed, &Frame { body }).unwrap(); + reader.get_mut().extend_from_slice(&framed); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + let err = host + .analyze_file(&sample) + .expect_err("error-payload response must surface as Err"); + match err { + HostError::Protocol(e) => { + assert_eq!(e.code, -32_001); + assert!( + e.message.contains("refused"), + "error message must pass through; got: {:?}", + e.message + ); + } + other => panic!("expected HostError::Protocol; got {other:?}"), + } + } + + // ── Content-Length ceiling through PluginHost ──────────────────────────── + + /// An oversize response frame surfaces as `HostError::Transport(FrameTooLarge)` + /// through `PluginHost::analyze_file`. `transport_03` tests the + /// transport layer in isolation but the host-level wiring was + /// previously untested — A.2.3's "8 MiB Content-Length ceiling has + /// both positive and negative tests" was only half true. + /// + /// Uses a tight artificial ceiling (1 KiB) so the pathological frame + /// is small enough to build in a test. + /// + /// Closes clarion-58eb4567b6. + #[test] + fn content_length_ceiling_surfaces_through_plugin_host() { + // Build host with a tight ceiling. + let manifest = compliant_manifest(); + let project_dir = TempDir::new().expect("tmpdir"); + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + // Prepare the initialize response using the usual mock path, then + // manually reconstruct a PluginHost with a 1-KiB ceiling. + let mut resp_mock = MockPlugin::new_compliant(); + let init_req = crate::plugin::protocol::make_request( + "initialize", + &InitializeParams { + protocol_version: "1.0".to_owned(), + project_root: project_dir.path().to_string_lossy().into_owned(), + }, + 1, + ); + let init_req_body = serde_json::to_vec(&init_req).unwrap(); + write_frame( + resp_mock.stdin(), + &Frame { + body: init_req_body, + }, + ) + .unwrap(); + resp_mock.tick().expect("tick init"); + let init_resp_bytes = drain_mock_output(&mut resp_mock); + + // Append an analyze_file response that's intentionally over the + // tight 1-KiB ceiling. + let mut all_bytes = init_resp_bytes; + let huge_payload = "x".repeat(2 * 1024); + let response_json = serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "result": { + "entities": [], + "padding": huge_payload, + } + }); + let response_body = serde_json::to_vec(&response_json).unwrap(); + let mut framed = Vec::new(); + write_frame( + &mut framed, + &Frame { + body: response_body, + }, + ) + .unwrap(); + all_bytes.extend_from_slice(&framed); + + let reader = Cursor::new(all_bytes); + let writer: Vec = Vec::new(); + let mut host = + PluginHost::new_inner(manifest, project_dir.path().to_path_buf(), reader, writer); + host.ceiling = crate::plugin::limits::ContentLengthCeiling::new(1024); + host.handshake().expect("handshake must succeed"); + + let err = host + .analyze_file(&sample) + .expect_err("oversize analyze_file response must fail"); + match err { + HostError::Transport(TransportError::FrameTooLarge { observed, ceiling }) => { + assert!( + observed > ceiling, + "observed must exceed ceiling: {observed} > {ceiling}" + ); + assert_eq!(ceiling, 1024, "ceiling must match configured value"); + } + other => panic!("expected Transport(FrameTooLarge); got {other:?}"), + } + } + + // ── Cross-plugin identity fabrication ──────────────────────────────────── + + /// A plugin whose manifest declares `plugin_id = "mock"` must not be + /// able to emit an entity with `id = "python:function:foo"` — that + /// would let one plugin spoof another plugin's namespace and corrupt + /// the entities table's `plugin_id` column. + /// + /// T4 covers the wrong-qualified-name case; this test covers the + /// wrong-plugin-id-segment case, the highest-value identity- + /// fabrication scenario. + /// + /// Closes clarion-e7789f2f76. + #[test] + fn cross_plugin_plugin_id_spoof_is_rejected() { + let manifest = compliant_manifest(); // plugin_id = "mock" + let mut mock = MockPlugin::new_compliant(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + // Valid kind, valid qualified_name; only plugin_id segment is + // wrong. entity_id("mock", "function", "stub") would produce + // "mock:function:stub"; we emit "python:function:stub". + let response_id = host.next_request_id_test(); + let response_json = serde_json::json!({ + "jsonrpc": "2.0", + "id": response_id, + "result": { + "entities": [{ + "id": "python:function:stub", + "kind": "function", + "qualified_name": "stub", + "source": { "file_path": sample.to_string_lossy().into_owned() } + }] + } + }); + let body = serde_json::to_vec(&response_json).unwrap(); + { + let reader = host.reader_mut_test(); + let pos_before = reader.position(); + let old_end = reader.get_ref().len() as u64; + let mut framed: Vec = Vec::new(); + write_frame(&mut framed, &Frame { body }).unwrap(); + reader.get_mut().extend_from_slice(&framed); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + let result = host.analyze_file(&sample).expect("must not error"); + assert!( + result.is_empty(), + "cross-plugin-id entity must be dropped; got {} accepted", + result.len() + ); + let findings = host.take_findings(); + let count = findings + .iter() + .filter(|f| f.subcode == FINDING_ENTITY_ID_MISMATCH) + .count(); + assert_eq!( + count, 1, + "expected exactly one FINDING_ENTITY_ID_MISMATCH; got {count} in {findings:?}" + ); + } + + // ── Drain-until-match: stale frames discarded, matching accepted ───────── + + /// The drain-until-match helper (introduced for clarion-c08586a2da / + /// clarion-ff2831eec0) must accept a matching response that follows + /// one or more stale frames. Without this property, stale frames + /// would convert into false transport errors on the happy path. + /// + /// Sends a frame with id=99 (stale) followed by the real id=2 + /// response; `analyze_file` should succeed and return the entities. + /// + /// Closes clarion-049bbe44ce (response-id mismatch surface). + #[test] + fn analyze_file_drains_stale_frames_before_matching_response() { + let manifest = compliant_manifest(); + let mut mock = MockPlugin::new_compliant(); + let (mut host, project_dir) = connect_and_handshake(manifest, &mut mock); + + let sample = project_dir.path().join("sample.mock"); + std::fs::write(&sample, b"").unwrap(); + + let expected_id = host.next_request_id_test(); + + // Frame 1: stale response, wrong id. + let stale_json = serde_json::json!({ + "jsonrpc": "2.0", + "id": 999_999, + "result": { "entities": [] } + }); + let stale_body = serde_json::to_vec(&stale_json).unwrap(); + + // Frame 2: real response at expected id, with one compliant entity. + let real_json = serde_json::json!({ + "jsonrpc": "2.0", + "id": expected_id, + "result": { + "entities": [{ + "id": "mock:function:stub", + "kind": "function", + "qualified_name": "stub", + "source": { "file_path": sample.to_string_lossy().into_owned() } + }] + } + }); + let real_body = serde_json::to_vec(&real_json).unwrap(); + + let mut framed: Vec = Vec::new(); + write_frame(&mut framed, &Frame { body: stale_body }).unwrap(); + write_frame(&mut framed, &Frame { body: real_body }).unwrap(); + { + let reader = host.reader_mut_test(); + let pos_before = reader.position(); + let old_end = reader.get_ref().len() as u64; + reader.get_mut().extend_from_slice(&framed); + if pos_before == old_end { + reader.set_position(old_end); + } + } + + let result = host + .analyze_file(&sample) + .expect("drain-until-match must succeed past stale frame"); + assert_eq!( + result.len(), + 1, + "matching response must yield its entity; got {} entities", + result.len() + ); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + fn append_mock_output_to_host_reader(mock: &mut MockPlugin, host_reader: &mut Cursor>) { + let new_bytes = drain_mock_output(mock); + let old_pos = host_reader.position(); + let old_end = host_reader.get_ref().len() as u64; + host_reader.get_mut().extend_from_slice(&new_bytes); + if old_pos == old_end { + host_reader.set_position(old_end); + } + } + + fn drain_mock_output(mock: &mut MockPlugin) -> Vec { + let end = mock.stdout().get_ref().len() as u64; + let start = mock.stdout().position(); + let bytes = mock.stdout().get_ref() + [usize::try_from(start).unwrap()..usize::try_from(end).unwrap()] + .to_vec(); + mock.stdout().set_position(end); + bytes + } +} diff --git a/crates/clarion-core/src/plugin/jail.rs b/crates/clarion-core/src/plugin/jail.rs new file mode 100644 index 00000000..eef2a8a5 --- /dev/null +++ b/crates/clarion-core/src/plugin/jail.rs @@ -0,0 +1,253 @@ +//! Path-jail enforcement for the Clarion plugin host. +//! +//! Implements ADR-021 §2a: every file path that a plugin names — whether in a +//! request parameter or in a returned entity — must lie *inside* the project +//! root. The jail function resolves both paths via +//! [`std::fs::canonicalize`] (which follows symlinks per UQ-WP2-03) and +//! asserts a `starts_with` relationship. +//! +//! # Policy +//! +//! When `jail` returns `JailError::EscapedRoot`, the *caller* decides the +//! response. ADR-021 §2a specifies "drop-entity, not kill-plugin" on a first +//! offence; the [`PathEscapeBreaker`](super::limits::PathEscapeBreaker) in +//! `limits.rs` accumulates escape events and trips to "kill-plugin" after more +//! than 10 escapes in 60 seconds. Task 6 (the plugin supervisor) wires these +//! two pieces together. +//! +//! # Wire boundary +//! +//! JSON-RPC frames carry paths as UTF-8 strings. Use [`jail_to_string`] at +//! the wire boundary; it calls [`jail`] and then converts the canonical +//! [`PathBuf`] to `String`, returning [`JailError::NonUtf8Path`] if the +//! canonicalized path is not valid UTF-8. + +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Errors returned by [`jail`] and [`jail_to_string`]. +#[derive(Debug, Error)] +pub enum JailError { + /// The candidate path resolves outside the root. + /// + /// ADR-021 §2a — the supervisor must record this escape against the plugin's + /// [`PathEscapeBreaker`](super::limits::PathEscapeBreaker) tally before + /// deciding whether to drop the entity or kill the plugin. + #[error("path escape: {offending:?} resolves outside the jail root")] + EscapedRoot { offending: PathBuf }, + + /// [`std::fs::canonicalize`] failed — the candidate or root does not exist, + /// or a permission error occurred. + #[error("jail canonicalize error: {0}")] + Io(#[from] std::io::Error), + + /// The canonicalized path is not valid UTF-8 (returned only by + /// [`jail_to_string`], never by [`jail`]). + #[error("path is not valid UTF-8: {offending:?}")] + NonUtf8Path { offending: PathBuf }, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Assert that `candidate` is inside `root` after symlink resolution. +/// +/// Both paths are canonicalized via [`std::fs::canonicalize`] before +/// comparison (UQ-WP2-03: symlinks must be followed so that a symlink inside +/// the root pointing outside is caught, not tolerated). +/// +/// # Returns +/// +/// - `Ok(canonical_candidate)` — the resolved, confirmed-safe path. +/// - `Err(JailError::EscapedRoot)` — `candidate` resolves outside `root`. +/// - `Err(JailError::Io)` — either path does not exist or cannot be resolved. +pub fn jail(root: &Path, candidate: &Path) -> Result { + let canonical_root = std::fs::canonicalize(root)?; + let canonical_candidate = std::fs::canonicalize(candidate)?; + + if !canonical_candidate.starts_with(&canonical_root) { + return Err(JailError::EscapedRoot { + offending: canonical_candidate, + }); + } + + Ok(canonical_candidate) +} + +/// Assert that `candidate` is inside `root` and return the canonical path as +/// a UTF-8 `String`. +/// +/// Calls [`jail`] then converts via `PathBuf::into_os_string().into_string()`. +/// Returns [`JailError::NonUtf8Path`] if the canonical path contains non-UTF-8 +/// bytes (platform-specific; possible on Linux where filenames are arbitrary +/// byte sequences). +/// +/// This is the form Task 6 uses at the JSON-RPC wire boundary. +pub fn jail_to_string(root: &Path, candidate: &Path) -> Result { + let canonical = jail(root, candidate)?; + canonical + .into_os_string() + .into_string() + .map_err(|os_str| JailError::NonUtf8Path { + offending: PathBuf::from(os_str), + }) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tempfile::TempDir; + + use super::*; + + // ── Helper: build a real file inside a TempDir ──────────────────────────── + + fn make_file(dir: &TempDir, name: &str) -> PathBuf { + let path = dir.path().join(name); + std::fs::write(&path, b"").expect("create test file"); + path + } + + // ── jail_01: path inside root is admitted ───────────────────────────────── + + /// A path that genuinely resides inside the root is admitted and the + /// canonical path is returned. + #[test] + fn jail_01_inside_root_is_admitted() { + let root = TempDir::new().expect("tmpdir"); + let candidate = make_file(&root, "src.py"); + + let result = jail(root.path(), &candidate).expect("must succeed"); + assert!( + result.starts_with(root.path().canonicalize().unwrap()), + "canonical path must start with canonical root" + ); + } + + // ── jail_02: `..`-based escape is rejected with EscapedRoot ────────────── + + /// A path constructed with `..` that resolves *above* the root must be + /// rejected. `std::fs::canonicalize` resolves the `..` before the + /// `starts_with` check, so there is no TOCTOU window. + #[test] + fn jail_02_dotdot_escape_returns_escaped_root() { + let root = TempDir::new().expect("tmpdir"); + // Create a subdir inside root so the `..`-path is resolv-able. + let subdir = root.path().join("sub"); + std::fs::create_dir(&subdir).expect("mkdir sub"); + // Create a sibling TempDir outside the root. Both live under the same + // OS temp directory (e.g. /tmp), so we can reach `outside_file` by + // going `subdir/../..//secret.py`. + let outside_root = TempDir::new().expect("outside tmpdir"); + // Create the file so canonicalize can resolve it; we navigate to it by + // dir-name + filename rather than storing the PathBuf return value. + make_file(&outside_root, "secret.py"); + + // `subdir` is `/sub`. One `..` reaches ``; a second `..` + // reaches ``'s parent (the OS temp dir). From there we use only + // the dir-name of `outside_root` + the hardcoded filename so the path + // stays within the temp directory tree. + let outside_dir_name = outside_root + .path() + .file_name() + .expect("outside TempDir must have a file name"); + let escape = subdir + .join("../..") + .join(outside_dir_name) + .join("secret.py"); + + assert!( + escape.exists(), + "escape path must exist — both TempDirs should live under the same parent" + ); + let err = jail(root.path(), &escape).expect_err("must reject escape"); + assert!( + matches!(err, JailError::EscapedRoot { .. }), + "expected EscapedRoot, got: {err:?}" + ); + } + + // ── jail_03: symlink inside root pointing outside is rejected ───────────── + + /// A symlink that physically lives inside the root but *targets* a path + /// outside the root must be rejected. This is the UQ-WP2-03 resolution: + /// `canonicalize` follows symlinks, so the resolved path escapes. + #[cfg(unix)] + #[test] + fn jail_03_symlink_inside_root_pointing_outside_is_rejected() { + let root = TempDir::new().expect("root tmpdir"); + let outside = TempDir::new().expect("outside tmpdir"); + let outside_file = make_file(&outside, "outside.py"); + + // Create a symlink inside the root whose target is the outside file. + let link_path = root.path().join("link.py"); + std::os::unix::fs::symlink(&outside_file, &link_path).expect("create symlink"); + + let err = jail(root.path(), &link_path).expect_err("symlink escape must be rejected"); + assert!( + matches!(err, JailError::EscapedRoot { .. }), + "expected EscapedRoot for symlink escape, got: {err:?}" + ); + } + + // ── jail_04: non-existent candidate is rejected with Io ────────────────── + + /// A path that does not exist on the filesystem cannot be canonicalized; + /// `jail` returns `JailError::Io`. + #[test] + fn jail_04_nonexistent_candidate_returns_io_error() { + let root = TempDir::new().expect("tmpdir"); + let missing = root.path().join("does_not_exist.py"); + + let err = jail(root.path(), &missing).expect_err("nonexistent path must error"); + assert!( + matches!(err, JailError::Io(_)), + "expected JailError::Io for nonexistent path, got: {err:?}" + ); + } + + // ── jail_05: non-UTF-8 path is rejected by jail_to_string ──────────────── + + /// On Unix, filenames are arbitrary byte sequences. A file whose name is + /// not valid UTF-8 passes `jail` (returns `Ok(PathBuf)`) but fails + /// `jail_to_string` with `JailError::NonUtf8Path`. + #[cfg(unix)] + #[test] + fn jail_05_non_utf8_path_rejected_by_jail_to_string() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let root = TempDir::new().expect("tmpdir"); + + // Construct a filename byte sequence that is not valid UTF-8. + // 0xFF 0xFE are invalid UTF-8 start bytes. + let bad_bytes: &[u8] = &[0xff, 0xfe, b'.', b'p', b'y']; + let bad_name = OsStr::from_bytes(bad_bytes); + let bad_path = root.path().join(bad_name); + + // Write an actual file with that name so canonicalize can resolve it. + std::fs::write(&bad_path, b"").expect("create non-UTF-8 file"); + + // `jail` itself should succeed — it returns a PathBuf. + let canonical = + jail(root.path(), &bad_path).expect("jail must succeed for valid (non-UTF-8) path"); + // The canonical path must be non-UTF-8 for this test to be meaningful. + assert!( + canonical.to_str().is_none(), + "canonical path should be non-UTF-8; if this fails, the OS normalised the name" + ); + + // `jail_to_string` must fail with NonUtf8Path. + let err = jail_to_string(root.path(), &bad_path) + .expect_err("jail_to_string must fail for non-UTF-8 path"); + assert!( + matches!(err, JailError::NonUtf8Path { .. }), + "expected NonUtf8Path, got: {err:?}" + ); + } +} diff --git a/crates/clarion-core/src/plugin/limits.rs b/crates/clarion-core/src/plugin/limits.rs new file mode 100644 index 00000000..9f0dc40d --- /dev/null +++ b/crates/clarion-core/src/plugin/limits.rs @@ -0,0 +1,552 @@ +//! Core-enforced resource limits for the plugin host. +//! +//! Implements ADR-021 §2a–§2d: ceilings and circuit-breakers that plugins +//! cannot opt out of. All four minimums are defined here: +//! +//! | ADR ref | Minimum | Type | +//! |-----------|--------------------------|-------------------------| +//! | ADR-021 §2a | Path-escape breaker | [`PathEscapeBreaker`] | +//! | ADR-021 §2b | Content-Length ceiling | [`ContentLengthCeiling`]| +//! | ADR-021 §2c | Entity/edge/finding cap| [`EntityCountCap`] | +//! | ADR-021 §2d | Virtual-address limit | [`apply_prlimit_as`] | +//! +//! # Deferred: CLA-INFRA-PLUGIN-ENTITY-OVERRUN-WARNING +//! +//! ADR-021 §2c also calls for a *warning* finding emitted when the cap is +//! approached (e.g. at 80 % of `DEFAULT_MAX`). This warning finding +//! (`CLA-INFRA-PLUGIN-ENTITY-OVERRUN-WARNING`) is **not implemented in +//! Sprint 1**. It requires the Filigree scan-result ingest path that lands in +//! WP5/WP6; deferring avoids a hard dependency on that infrastructure. +//! When the ingest path is ready, add a `try_admit_with_warning` variant that +//! emits the warning finding before returning `Ok`. +//! +//! # Finding subcode constants +//! +//! Task 6 (the plugin supervisor) imports these constants to emit findings into +//! Filigree. The constants are defined here because they describe limit-domain +//! violations; the transport and jail modules do not emit findings themselves. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use thiserror::Error; + +// ── Finding subcode constants (ADR-021, consumed by Task 6) ────────────────── + +/// Finding subcode emitted when a plugin returns a path that escapes the jail. +pub const FINDING_PATH_ESCAPE: &str = "CLA-INFRA-PLUGIN-PATH-ESCAPE"; + +/// Finding subcode emitted when the path-escape breaker trips and the plugin +/// is killed (the "disabled" sense: further entities from this plugin are +/// refused entirely). +pub const FINDING_DISABLED_PATH_ESCAPE: &str = "CLA-INFRA-PLUGIN-DISABLED-PATH-ESCAPE"; + +/// Finding subcode emitted when `read_frame` rejects a frame because its +/// `Content-Length` exceeds the configured [`ContentLengthCeiling`]. +pub const FINDING_FRAME_OVERSIZE: &str = "CLA-INFRA-PLUGIN-FRAME-OVERSIZE"; + +/// Finding subcode emitted when [`EntityCountCap::try_admit`] returns +/// [`CapExceeded`] and the supervisor stops processing plugin output. +pub const FINDING_ENTITY_CAP: &str = "CLA-INFRA-PLUGIN-ENTITY-CAP"; + +/// Finding subcode emitted when the plugin process is killed by the OS due to +/// exceeding its `RLIMIT_AS` memory ceiling (OOM kill on `RLIMIT_AS`). +pub const FINDING_OOM_KILLED: &str = "CLA-INFRA-PLUGIN-OOM-KILLED"; + +// ── ContentLengthCeiling (ADR-021 §2b) ─────────────────────────────────────── + +/// Maximum permitted `Content-Length` value on an incoming plugin frame. +/// +/// ADR-021 §2b sets the default at **8 MiB**. The operator may supply a lower +/// value via configuration; the 1 MiB config-surface floor mentioned in +/// ADR-021 is a deployment concern (not enforced in `new()`) — Sprint 1 +/// hardcodes the default and does not expose configuration. +/// +/// `Copy` — this is a single `usize`; pass by value throughout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ContentLengthCeiling(usize); + +impl ContentLengthCeiling { + /// Default ceiling: 8 MiB per ADR-021 §2b. + pub const DEFAULT: Self = Self(8 * 1024 * 1024); + + /// Construct a ceiling with an explicit byte limit. + /// + /// The 1 MiB minimum floor from ADR-021 is a *configuration-surface* + /// constraint, not enforced here — callers that construct a ceiling below + /// 1 MiB are doing so deliberately (e.g. tight test budgets). + pub const fn new(bytes: usize) -> Self { + Self(bytes) + } + + /// A sentinel ceiling that never fires — equivalent to `usize::MAX`. + /// + /// Use in tests that do not care about the frame-size limit. + /// + /// Gated behind `#[cfg(test)]` (production code would use this to + /// `vec![0u8; isize::MAX]` on a malicious Content-Length and OOM-kill + /// the host). Production callers must pass an explicit ceiling; the + /// ADR-021 default is [`Self::DEFAULT`] at 8 MiB. + #[cfg(test)] + pub const fn unbounded() -> Self { + Self(usize::MAX) + } + + /// Return the ceiling value in bytes. + pub const fn get(self) -> usize { + self.0 + } +} + +impl Default for ContentLengthCeiling { + fn default() -> Self { + Self::DEFAULT + } +} + +// ── EntityCountCap error (ADR-021 §2c) ─────────────────────────────────────── + +/// Error returned by [`EntityCountCap::try_admit`] when the run-wide limit +/// would be exceeded. +/// +/// ADR-021 §2c: entities, edges, and findings are all counted against the same +/// cap; the `delta` passed to `try_admit` is the combined increment. +#[derive(Debug, Error, PartialEq, Eq)] +#[error("entity cap exceeded: {would_reach} items would be reached (cap {cap})")] +pub struct CapExceeded { + /// The cumulative count that would have been reached. + pub would_reach: usize, + /// The configured cap. + pub cap: usize, +} + +// ── EntityCountCap (ADR-021 §2c) ───────────────────────────────────────────── + +/// Cumulative counter guarding the run-wide entity + edge + finding cap. +/// +/// ADR-021 §2c: the default cap is **500,000 items** (entities + edges + +/// findings combined). The supervisor calls [`try_admit`](Self::try_admit) +/// for each batch of items admitted from a plugin response; when the cap would +/// be exceeded the supervisor emits [`FINDING_ENTITY_CAP`] and stops +/// processing further plugin output. +/// +/// `&mut self` — the cap lives inside the host's per-run state; Sprint 1 has +/// no cross-thread sharing of this value. +pub struct EntityCountCap { + max: usize, + consumed: usize, +} + +impl EntityCountCap { + /// Default cap: 500,000 items per ADR-021 §2c. + pub const DEFAULT_MAX: usize = 500_000; + + /// Construct a cap with the given maximum. + pub fn new(max: usize) -> Self { + Self { max, consumed: 0 } + } + + /// Attempt to admit `delta` more items. + /// + /// Returns `Ok(())` if `consumed + delta <= max`; otherwise returns + /// [`CapExceeded`] and leaves `consumed` unchanged. + /// + /// The boundary case (`consumed + delta == max`) is admitted — the cap + /// is reached but not exceeded. + pub fn try_admit(&mut self, delta: usize) -> Result<(), CapExceeded> { + let next = self.consumed.saturating_add(delta); + if next > self.max { + return Err(CapExceeded { + would_reach: next, + cap: self.max, + }); + } + self.consumed = next; + Ok(()) + } + + /// Current cumulative count of admitted items. + pub fn consumed(&self) -> usize { + self.consumed + } +} + +// ── PathEscapeBreaker (ADR-021 §2a) ────────────────────────────────────────── + +/// State returned by [`PathEscapeBreaker::record_escape`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BreakerState { + /// The breaker is within the escape threshold; the supervisor should drop + /// the offending entity but keep the plugin alive. + Open, + /// The threshold has been exceeded; the supervisor should kill the plugin + /// and emit [`FINDING_DISABLED_PATH_ESCAPE`]. + Tripped, +} + +/// Rolling-window escape counter per ADR-021 §2a. +/// +/// Trips when **more than 10** path escapes occur within a 60-second window. +/// The `>10` threshold (not `>=10`) is specified by ADR-021 §2a. +/// +/// # Clock injection +/// +/// The public API uses [`Instant::now()`] internally. Tests (and Task 6's +/// host code) use the crate-internal `record_escape_at` helper to inject +/// arbitrary instants without sleeping. +pub struct PathEscapeBreaker { + /// Rolling window length — default 60 s per ADR-021 §2a. + window: Duration, + /// Breaker trips when `events.len() > threshold` after pruning. + threshold: usize, + /// Timestamps of recent path-escape events within the window. + events: VecDeque, +} + +impl PathEscapeBreaker { + /// Default window: 60 seconds per ADR-021 §2a. + pub const DEFAULT_WINDOW: Duration = Duration::from_secs(60); + /// Default threshold: 10 per ADR-021 §2a (trips on the **11th** escape). + pub const DEFAULT_THRESHOLD: usize = 10; + + /// Construct a breaker with explicit window and threshold. + pub fn new(window: Duration, threshold: usize) -> Self { + Self { + window, + threshold, + events: VecDeque::new(), + } + } + + /// Construct a breaker with the ADR-021 §2a defaults. + pub fn new_default() -> Self { + Self::new(Self::DEFAULT_WINDOW, Self::DEFAULT_THRESHOLD) + } + + /// Record a path-escape event at `Instant::now()` and return the new + /// breaker state. + pub fn record_escape(&mut self) -> BreakerState { + self.record_escape_at(Instant::now()) + } + + /// Test hook — accepts an injected `Instant` to make rolling-window pruning + /// deterministic under test. Also used by Task 6's host code (same crate) + /// when a precise timestamp is available. Not part of the public API. + pub(crate) fn record_escape_at(&mut self, at: Instant) -> BreakerState { + self.events.push_back(at); + // Prune events outside the rolling window relative to `at`. + self.events.retain(|&t| { + // Keep events where `at - t < window`, i.e., `t > at - window`. + // `at.checked_duration_since(t)` is `None` if `t > at` (future instant, + // possible with injected clocks) — treat those as "just happened" (keep). + at.checked_duration_since(t) + .is_none_or(|age| age < self.window) + }); + + if self.events.len() > self.threshold { + BreakerState::Tripped + } else { + BreakerState::Open + } + } +} + +// ── apply_prlimit_as (ADR-021 §2d) ─────────────────────────────────────────── + +/// Default virtual-address space ceiling per ADR-021 §2d: **2 GiB**. +/// +/// Applied via `RLIMIT_AS` in the plugin's child process before `exec`. +/// Task 6 calls `apply_prlimit_as` inside `CommandExt::pre_exec`. +pub const DEFAULT_MAX_RSS_MIB: u64 = 2 * 1024; // 2 GiB + +/// Compute the effective RSS ceiling as the minimum of the manifest value and +/// the core default. +/// +/// ADR-021 §2d: effective limit = `min(manifest.capabilities.runtime.expected_max_rss_mb, core_default)`. +/// A manifest value of 0 is treated as "unset" and the core default wins. +pub fn effective_rss_mib(manifest_value: u64, core_default: u64) -> u64 { + if manifest_value == 0 { + return core_default; + } + manifest_value.min(core_default) +} + +/// Default file-descriptor ceiling applied to the plugin child. Plugin +/// work is ingest-style (open, parse, close — one file at a time); the +/// host's own minimum is much smaller than the shell's usual 1024/65535 +/// `RLIMIT_NOFILE`. 256 covers any realistic concurrent-open pattern a +/// Sprint 1 plugin needs while preventing fd-flood `DoS` of the host's +/// own operations (logging, writer-actor `SQLite`). +pub const DEFAULT_MAX_NOFILE: u64 = 256; + +/// Default process/thread ceiling applied to the plugin child. Sprint 1 +/// plugins are single-threaded or run a tight tokio pool; 32 is an +/// order-of-magnitude ceiling that accommodates legitimate thread-pool +/// use while stopping fork-bomb / thread-flood `DoS`. Plugins that need +/// higher can negotiate via manifest extension in a later sprint; for +/// now the fixed default covers every planned v0.1 consumer. +pub const DEFAULT_MAX_NPROC: u64 = 32; + +/// Apply `RLIMIT_AS` to the current process. +/// +/// Called inside `CommandExt::pre_exec` (Task 6) so the limit applies to the +/// plugin child process, not the Clarion host process. Setting the limit in +/// `pre_exec` is safe because `pre_exec` runs after `fork()` but before +/// `exec()`, so only the child's address-space limit is affected. +/// +/// # Errors +/// +/// Returns `std::io::Error` on `setrlimit` failure. +#[cfg(target_os = "linux")] +pub fn apply_prlimit_as(max_rss_mib: u64) -> std::io::Result<()> { + use nix::sys::resource::{Resource, setrlimit}; + + let bytes = max_rss_mib.saturating_mul(1024 * 1024); + setrlimit(Resource::RLIMIT_AS, bytes, bytes).map_err(std::io::Error::from) +} + +/// Apply `RLIMIT_NOFILE` and `RLIMIT_NPROC` to the current process. +/// +/// Called from the same `pre_exec` closure as [`apply_prlimit_as`]. Same +/// async-signal-safety notes apply — `setrlimit` is on the POSIX AS-safe +/// list, no allocation occurs, no Rust Drop runs. Failure from either +/// setrlimit returns `Err` and the stdlib `pre_exec` machinery aborts +/// the child via `_exit` before `exec`. +/// +/// # Errors +/// +/// Returns `std::io::Error` on the first `setrlimit` failure. +#[cfg(target_os = "linux")] +pub fn apply_prlimit_nofile_nproc(max_nofile: u64, max_nproc: u64) -> std::io::Result<()> { + use nix::sys::resource::{Resource, setrlimit}; + + setrlimit(Resource::RLIMIT_NOFILE, max_nofile, max_nofile).map_err(std::io::Error::from)?; + setrlimit(Resource::RLIMIT_NPROC, max_nproc, max_nproc).map_err(std::io::Error::from) +} + +/// Non-Linux stub for [`apply_prlimit_nofile_nproc`]. +#[cfg(not(target_os = "linux"))] +pub fn apply_prlimit_nofile_nproc(_max_nofile: u64, _max_nproc: u64) -> std::io::Result<()> { + Ok(()) +} + +/// No-op stub for non-Linux targets (UQ-WP2-06: Linux-only for Sprint 1). +/// +/// Logs a one-time warning and returns `Ok(())`. The caller proceeds without a +/// memory ceiling on the plugin process. +#[cfg(not(target_os = "linux"))] +pub fn apply_prlimit_as(_max_rss_mib: u64) -> std::io::Result<()> { + warn_once_non_linux(); + Ok(()) +} + +/// Emit a one-time warning on non-Linux platforms. +/// +/// Uses `std::sync::Once` rather than `tracing` — clarion-core has no tracing +/// dep and we do not add one for this single warning (per task spec). +#[cfg(not(target_os = "linux"))] +fn warn_once_non_linux() { + use std::sync::Once; + static WARN: Once = Once::new(); + WARN.call_once(|| { + eprintln!( + "clarion: RLIMIT_AS enforcement is Linux-only; \ + plugin memory ceiling will not be applied on this platform" + ); + }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── ContentLengthCeiling tests ──────────────────────────────────────────── + + /// DEFAULT equals 8 MiB. + #[test] + fn ceiling_default_is_8_mib() { + assert_eq!( + ContentLengthCeiling::DEFAULT.get(), + 8 * 1024 * 1024, + "DEFAULT must be 8 MiB per ADR-021 §2b" + ); + } + + /// `new` and `get` round-trip. + #[test] + fn ceiling_new_get_round_trip() { + let c = ContentLengthCeiling::new(12345); + assert_eq!(c.get(), 12345); + } + + /// `Default` impl returns the same as `DEFAULT`. + #[test] + fn ceiling_default_impl_matches_constant() { + assert_eq!( + ContentLengthCeiling::default().get(), + ContentLengthCeiling::DEFAULT.get() + ); + } + + /// `unbounded()` returns `usize::MAX`. + #[test] + fn ceiling_unbounded_is_usize_max() { + assert_eq!(ContentLengthCeiling::unbounded().get(), usize::MAX); + } + + // ── EntityCountCap tests ────────────────────────────────────────────────── + + /// Admit under the cap → Ok. + #[test] + fn cap_admit_under_limit_returns_ok() { + let mut cap = EntityCountCap::new(100); + assert!(cap.try_admit(50).is_ok()); + assert_eq!(cap.consumed(), 50); + } + + /// Admit to the exact boundary → Ok (boundary is inclusive). + #[test] + fn cap_admit_at_exact_boundary_returns_ok() { + let mut cap = EntityCountCap::new(100); + assert!(cap.try_admit(100).is_ok()); + assert_eq!(cap.consumed(), 100); + } + + /// Admit one over the boundary → `CapExceeded`. + #[test] + fn cap_admit_over_boundary_returns_cap_exceeded() { + let mut cap = EntityCountCap::new(100); + let err = cap.try_admit(101).expect_err("should exceed cap"); + assert_eq!(err.cap, 100); + assert_eq!(err.would_reach, 101); + // consumed must be unchanged after a failed admit. + assert_eq!(cap.consumed(), 0, "failed admit must not modify consumed"); + } + + /// Cumulative admits: multiple calls accumulate correctly. + #[test] + fn cap_cumulative_admits_accumulate() { + let mut cap = EntityCountCap::new(500_000); + for _ in 0..9 { + cap.try_admit(50_000).expect("under cap"); + } + assert_eq!(cap.consumed(), 450_000); + // One more batch of 50k hits exact boundary. + cap.try_admit(50_000).expect("at exact boundary"); + assert_eq!(cap.consumed(), 500_000); + // One more item tips over. + let err = cap.try_admit(1).expect_err("must exceed"); + assert_eq!(err.cap, 500_000); + } + + // ── PathEscapeBreaker tests ─────────────────────────────────────────────── + + /// 10 escapes → Open (threshold is >10, not >=10). + #[test] + fn breaker_ten_escapes_returns_open() { + let mut b = PathEscapeBreaker::new_default(); + let t = Instant::now(); + let mut state = BreakerState::Open; + for _ in 0..10 { + state = b.record_escape_at(t); + } + assert_eq!( + state, + BreakerState::Open, + "10 escapes must not trip the breaker (threshold is >10)" + ); + } + + /// 11th escape → Tripped. + #[test] + fn breaker_eleventh_escape_returns_tripped() { + let mut b = PathEscapeBreaker::new_default(); + let t = Instant::now(); + for _ in 0..10 { + b.record_escape_at(t); + } + let state = b.record_escape_at(t); + assert_eq!( + state, + BreakerState::Tripped, + "11th escape must trip the breaker" + ); + } + + /// Events older than the window are pruned; only recent events count. + /// + /// Push 10 events at t0, then 1 event at t0+61s → Open (10 old events + /// pruned, only 1 within-window). Then one more → still Open (2 in window). + #[test] + fn breaker_old_events_pruned_outside_window() { + let mut b = PathEscapeBreaker::new_default(); + let t0 = Instant::now(); + let t1 = t0 + Duration::from_secs(61); // outside 60s window + + for _ in 0..10 { + b.record_escape_at(t0); + } + // 10 events at t0. Now record at t1 — t0-events are >60s old from t1. + let state = b.record_escape_at(t1); + assert_eq!( + state, + BreakerState::Open, + "after pruning 10 old events, 1 within-window event must leave breaker Open" + ); + + // One more at t1 → 2 within-window events → still Open. + let state2 = b.record_escape_at(t1); + assert_eq!( + state2, + BreakerState::Open, + "2 events in window must be Open" + ); + } + + // ── effective_rss_mib tests ─────────────────────────────────────────────── + + /// Manifest value smaller than core default → manifest wins. + #[test] + fn effective_rss_mib_manifest_wins_when_smaller() { + assert_eq!(effective_rss_mib(256, 2048), 256); + } + + /// Manifest value larger than core default → core default wins. + #[test] + fn effective_rss_mib_core_ceiling_wins_when_larger() { + assert_eq!(effective_rss_mib(4096, 2048), 2048); + } + + /// Manifest value of 0 → treated as unset, core default used. + #[test] + fn effective_rss_mib_zero_manifest_uses_core_default() { + assert_eq!(effective_rss_mib(0, 2048), 2048); + } + + // ── apply_prlimit_as tests ──────────────────────────────────────────────── + + /// On Linux: calling `apply_prlimit_as` with the default ceiling returns Ok. + /// + /// This sets `RLIMIT_AS` on the test process itself, which is safe — tests + /// run well under 2 GiB. Note: this test sets **both the soft and hard** + /// `RLIMIT_AS` to `DEFAULT_MAX_RSS_MIB` (2 GiB) on the test binary's + /// process. Any subsequent test in the same binary cannot raise the limit + /// above 2 GiB without root privileges. + #[cfg(target_os = "linux")] + #[test] + fn apply_prlimit_linux_returns_ok() { + let result = apply_prlimit_as(DEFAULT_MAX_RSS_MIB); + assert!(result.is_ok(), "apply_prlimit_as must succeed: {result:?}"); + } + + /// On non-Linux: the stub path compiles and returns Ok (type-level check). + #[cfg(not(target_os = "linux"))] + #[test] + fn apply_prlimit_non_linux_stub_returns_ok() { + let result = apply_prlimit_as(DEFAULT_MAX_RSS_MIB); + assert!(result.is_ok()); + } +} diff --git a/crates/clarion-core/src/plugin/manifest.rs b/crates/clarion-core/src/plugin/manifest.rs new file mode 100644 index 00000000..3ffbda40 --- /dev/null +++ b/crates/clarion-core/src/plugin/manifest.rs @@ -0,0 +1,1482 @@ +//! Plugin manifest parser and validator. +//! +//! Implements the L5 `plugin.toml` schema per ADR-022 and ADR-021 §Layer 1. +//! +//! # Usage +//! +//! ```no_run +//! use clarion_core::plugin::parse_manifest; +//! +//! let bytes = std::fs::read("plugin.toml").unwrap(); +//! let manifest = parse_manifest(&bytes).unwrap(); +//! ``` +//! +//! After parsing, call [`Manifest::validate_for_v0_1`] to run the +//! ADR-021 §Layer 1 capability checks that the supervisor (Task 6) needs +//! before completing the `initialize` handshake. + +use std::collections::BTreeMap; + +use serde::Deserialize; +use thiserror::Error; + +use crate::entity_id::validate_kind_grammar; + +// ── Reserved lists (ADR-022) ────────────────────────────────────────────────── + +/// Entity kinds the core owns; plugins may not declare these (ADR-022 §Core owns). +const RESERVED_ENTITY_KINDS: &[&str] = &["file", "subsystem", "guidance"]; + +/// Rule-ID prefixes the core owns; plugins may not claim these (ADR-022 §Core owns). +/// +/// `CLA-INFRA-` is core/pipeline-only; `CLA-FACT-` is shared (core or any tool may +/// emit) but a plugin manifest may not claim it as *the plugin's* prefix. +const RESERVED_RULE_ID_PREFIXES: &[&str] = &["CLA-INFRA-", "CLA-FACT-"]; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Errors returned by [`parse_manifest`] and [`Manifest::validate_for_v0_1`]. +/// +/// Each variant corresponds to a `CLA-INFRA-MANIFEST-*` finding code that Task 6 +/// surfaces in the `initialize` handshake reply. Use [`ManifestError::subcode`] to +/// obtain the machine-readable finding code. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ManifestError { + /// TOML parse failure or a required field is absent. + /// + /// Finding code: `CLA-INFRA-MANIFEST-MALFORMED`. + #[error("CLA-INFRA-MANIFEST-MALFORMED: {message}")] + Malformed { message: String }, + + /// An identifier string fails the ADR-022 grammar `[a-z][a-z0-9_]*` (kinds) + /// or `CLA-[A-Z]+(-[A-Z0-9]+)*-` (rule-ID prefix). + /// + /// Finding code: `CLA-INFRA-MANIFEST-MALFORMED`. + #[error("CLA-INFRA-MANIFEST-MALFORMED: {field} {value:?} violates ADR-022 identifier grammar")] + GrammarViolation { field: &'static str, value: String }, + + /// A plugin manifest declares one of the core-reserved entity kinds. + /// + /// Finding code: `CLA-INFRA-MANIFEST-RESERVED-KIND`. + #[error( + "CLA-INFRA-MANIFEST-RESERVED-KIND: entity kind {kind:?} is reserved by the core (ADR-022)" + )] + ReservedKind { kind: String }, + + /// A plugin manifest claims a rule-ID prefix owned by the core. + /// + /// Finding code: `CLA-INFRA-RULE-ID-NAMESPACE`. + #[error( + "CLA-INFRA-RULE-ID-NAMESPACE: rule_id_prefix {prefix:?} is a core-reserved namespace (ADR-022)" + )] + ReservedPrefix { prefix: String }, + + /// A manifest declares a capability that v0.1 does not support. + /// + /// Finding code: `CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY`. + /// + /// This variant is produced by [`Manifest::validate_for_v0_1`], not by + /// [`parse_manifest`]. The parser accepts the field faithfully; Task 6's + /// supervisor calls `validate_for_v0_1` and surfaces this error as a + /// handshake rejection. + #[error( + "CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY: capability {capability:?} is not supported in v0.1" + )] + UnsupportedCapability { capability: &'static str }, +} + +impl ManifestError { + /// Return the machine-readable finding code for this error. + /// + /// Task 6 uses this to populate the `rule_id` field of the `CLA-INFRA-*` + /// finding emitted when a plugin fails to start. + pub fn subcode(&self) -> &'static str { + match self { + ManifestError::Malformed { .. } | ManifestError::GrammarViolation { .. } => { + "CLA-INFRA-MANIFEST-MALFORMED" + } + ManifestError::ReservedKind { .. } => "CLA-INFRA-MANIFEST-RESERVED-KIND", + ManifestError::ReservedPrefix { .. } => "CLA-INFRA-RULE-ID-NAMESPACE", + ManifestError::UnsupportedCapability { .. } => { + "CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY" + } + } + } +} + +// ── Manifest structs ────────────────────────────────────────────────────────── + +/// Top-level `plugin.toml` manifest. +/// +/// Serde deserialises from TOML. `#[serde(deny_unknown_fields)]` is intentionally +/// absent at the top level so that future `[integrations.*]` blocks (WP3) parse +/// without error. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Manifest { + /// `[plugin]` table. + pub plugin: PluginMeta, + + /// `[capabilities]` table. + pub capabilities: Capabilities, + + /// `[ontology]` table. + pub ontology: Ontology, + + /// `[integrations.*]` — optional, opaque passthrough for plugin-specific + /// integration config (e.g. WP3's `[integrations.wardline]`). + /// + /// The core does not interpret this table; it is preserved so Task 6 can + /// forward it to the plugin during `initialize` if needed. + /// + /// Entry count is capped at [`MAX_INTEGRATIONS`] at parse time — see + /// [`parse_manifest`]. + #[serde(default)] + pub integrations: BTreeMap, +} + +/// Maximum number of `[integrations.*]` entries accepted per manifest. +/// +/// A typical plugin has 0–1 integration blocks (WP3 adds +/// `[integrations.wardline]`); 64 is an order-of-magnitude ceiling that +/// covers any legitimate future use while rejecting pathologies. +pub const MAX_INTEGRATIONS: usize = 64; + +/// `[plugin]` metadata table. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginMeta { + /// Package name, e.g. `"clarion-plugin-python"`. Informational; hyphens allowed. + pub name: String, + + /// Identifier fed to `entity_id()`, e.g. `"python"`. Must satisfy `[a-z][a-z0-9_]*` + /// per ADR-022. Distinct from `name` so human-readable package names (which may + /// contain hyphens) do not conflict with the entity-ID grammar. + pub plugin_id: String, + + /// Plugin version (semver), e.g. `"0.1.0"`. + pub version: String, + + /// Protocol version the plugin speaks, e.g. `"1.0"`. + pub protocol_version: String, + + /// Executable name (resolved via `$PATH` or neighboring manifest per L9). + pub executable: String, + + /// Informational language tag. + pub language: String, + + /// File extensions this plugin claims, e.g. `["py"]`. + pub extensions: Vec, +} + +/// `[capabilities]` table — wraps the ADR-021 §Layer 1 runtime sub-struct. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Capabilities { + /// `[capabilities.runtime]` — ADR-021 §Layer 1 declarations. + pub runtime: CapabilitiesRuntime, +} + +/// `[capabilities.runtime]` — ADR-021 §Layer 1 declarations. +/// +/// These are *declarations*, not enforcements. The core applies its own +/// absolute ceilings independently (L6, Task 4); effective limits are +/// `min(manifest, core_default)`. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CapabilitiesRuntime { + /// Plugin's own RSS estimate in MiB. Effective `prlimit` = `min(this, 2 GiB)`. + /// + /// Must be > 0. + pub expected_max_rss_mb: u64, + + /// Declared per-file entity budget. Exceeding triggers `CLA-INFRA-PLUGIN-ENTITY-OVERRUN-WARNING` + /// (implementation deferred to Tier B sprint). + pub expected_entities_per_file: u64, + + /// `true` if this plugin reads `wardline.core.registry.REGISTRY` (WP3 L8). + pub wardline_aware: bool, + + /// `true` if the plugin needs to read paths outside the project root. + /// + /// v0.1 refuses `true` at `initialize` with `CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY`. + /// The parser accepts the field faithfully; [`Manifest::validate_for_v0_1`] performs + /// the rejection check. + pub reads_outside_project_root: bool, +} + +/// `[ontology]` table — plugin-declared ontology per ADR-022. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Ontology { + /// Entity kinds this plugin emits. Must be non-empty; each must satisfy + /// `[a-z][a-z0-9_]*`; none may be in the core-reserved list. + pub entity_kinds: Vec, + + /// Edge kinds this plugin emits. May include the four core-reserved edge kinds + /// (`contains`, `guides`, `emits_finding`, `in_subsystem`) — listing them binds + /// the plugin to the core's fixed semantics for those kinds (ADR-022 §Core owns). + #[serde(default)] + pub edge_kinds: Vec, + + /// Rule-ID prefix, e.g. `"CLA-PY-"`. Must end with `-` and match + /// `CLA-[A-Z]+(-[A-Z0-9]+)*-`. Must not be a core-reserved prefix. + pub rule_id_prefix: String, + + /// Ontology version (semver). Bumped when entity/edge/rule set changes. + /// WP6 includes this in the cache key (ADR-007). + pub ontology_version: String, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +impl Manifest { + /// Run ADR-021 §Layer 1 capability checks. + /// + /// Called by Task 6's supervisor before sending `initialized` to ensure no + /// v0.1-unsupported capability is granted. Returns `Ok(())` if the manifest + /// is safe to proceed with, or a [`ManifestError::UnsupportedCapability`] if + /// a capability the core cannot honour is declared. + /// + /// Note: [`parse_manifest`] already validates grammar and reserved names. This + /// method only checks runtime capabilities that the core cannot satisfy. + pub fn validate_for_v0_1(&self) -> Result<(), ManifestError> { + if self.capabilities.runtime.reads_outside_project_root { + return Err(ManifestError::UnsupportedCapability { + capability: "reads_outside_project_root", + }); + } + Ok(()) + } +} + +/// Parse and validate a `plugin.toml` manifest from raw bytes. +/// +/// Performs: +/// 1. TOML deserialisation into [`Manifest`]. +/// 2. Structural checks (`name` non-empty, `extensions` non-empty, etc.). +/// 3. `entity_kinds` non-empty; each matches `[a-z][a-z0-9_]*`; none in reserved list. +/// 4. `edge_kinds` each matches `[a-z][a-z0-9_]*` (core-reserved edge kinds are allowed). +/// 5. `rule_id_prefix` grammar check, then reserved-prefix check. +/// 6. `expected_max_rss_mb > 0`. +/// +/// Does **not** check `reads_outside_project_root` — that is a v0.1 capability +/// restriction surfaced by [`Manifest::validate_for_v0_1`] at handshake time. +/// +/// # Errors +/// +/// Returns a [`ManifestError`] describing the first validation failure. +pub fn parse_manifest(bytes: &[u8]) -> Result { + // 1. TOML deserialise. + let text = std::str::from_utf8(bytes).map_err(|e| ManifestError::Malformed { + message: format!("manifest is not valid UTF-8: {e}"), + })?; + let manifest: Manifest = toml::from_str(text).map_err(|e| ManifestError::Malformed { + message: e.to_string(), + })?; + + // Cap the integrations table size. WP3 expects one entry + // (`[integrations.wardline]`); real-world plugin.toml files have at + // most a handful. MAX_INTEGRATIONS is a trust-boundary cap — an + // attacker-controlled plugin.toml with millions of `[integrations.*]` + // entries would otherwise live in memory for the run lifetime. + if manifest.integrations.len() > MAX_INTEGRATIONS { + return Err(ManifestError::Malformed { + message: format!( + "[integrations] has {} entries; maximum is {MAX_INTEGRATIONS}", + manifest.integrations.len(), + ), + }); + } + + // 2. Structural checks. + if manifest.plugin.name.is_empty() { + return Err(ManifestError::Malformed { + message: "[plugin].name must not be empty".to_owned(), + }); + } + // plugin_id must satisfy the ADR-022 kind grammar [a-z][a-z0-9_]*. + if manifest.plugin.plugin_id.is_empty() { + return Err(ManifestError::Malformed { + message: "[plugin].plugin_id must not be empty".to_owned(), + }); + } + if !validate_kind_grammar(&manifest.plugin.plugin_id) { + return Err(ManifestError::GrammarViolation { + field: "plugin_id", + value: manifest.plugin.plugin_id.clone(), + }); + } + if manifest.plugin.extensions.is_empty() { + return Err(ManifestError::Malformed { + message: "[plugin].extensions must not be empty".to_owned(), + }); + } + // Extension format: lowercase ASCII alphanumeric, no dot, at least 1 char. + // Grammar: [a-z][a-z0-9]* (matches what Path::extension() returns — no leading dot). + for ext in &manifest.plugin.extensions { + if ext.is_empty() + || !ext.starts_with(|c: char| c.is_ascii_lowercase()) + || !ext + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + { + return Err(ManifestError::GrammarViolation { + field: "extensions", + value: ext.clone(), + }); + } + } + + // 3. entity_kinds non-empty; grammar; reserved check. + if manifest.ontology.entity_kinds.is_empty() { + return Err(ManifestError::Malformed { + message: "[ontology].entity_kinds must declare at least one kind".to_owned(), + }); + } + for kind in &manifest.ontology.entity_kinds { + validate_kind_string("entity_kinds", kind)?; + if RESERVED_ENTITY_KINDS.iter().any(|r| *r == kind) { + return Err(ManifestError::ReservedKind { + kind: kind.to_owned(), + }); + } + } + + // 4. edge_kinds grammar (core-reserved names are permitted — they bind the + // plugin to core semantics per ADR-022, not redefine them). + for kind in &manifest.ontology.edge_kinds { + validate_kind_string("edge_kinds", kind)?; + } + + // 5. rule_id_prefix grammar then reserved check. + validate_rule_id_prefix_grammar(&manifest.ontology.rule_id_prefix)?; + if RESERVED_RULE_ID_PREFIXES + .iter() + .any(|r| *r == manifest.ontology.rule_id_prefix) + { + return Err(ManifestError::ReservedPrefix { + prefix: manifest.ontology.rule_id_prefix.clone(), + }); + } + + // 6. RSS bound. + if manifest.capabilities.runtime.expected_max_rss_mb == 0 { + return Err(ManifestError::Malformed { + message: "[capabilities.runtime].expected_max_rss_mb must be > 0".to_owned(), + }); + } + + Ok(manifest) +} + +// ── Grammar helpers ─────────────────────────────────────────────────────────── + +/// Validate a kind string against the ADR-022 grammar `[a-z][a-z0-9_]*`. +/// +/// Reuses [`validate_kind_grammar`] from `entity_id` — single canonical check. +fn validate_kind_string(field: &'static str, value: &str) -> Result<(), ManifestError> { + if !validate_kind_grammar(value) { + return Err(ManifestError::GrammarViolation { + field, + value: value.to_owned(), + }); + } + Ok(()) +} + +/// Validate a `rule_id_prefix` against the ADR-022 prefix grammar. +/// +/// Rules: +/// 1. Must end with `-`. +/// 2. Strip the trailing `-`; the remainder must match `CLA-[A-Z]+(-[A-Z0-9]+)*`. +/// Implementation: split on `-`, verify the first segment is `CLA`, and each +/// subsequent non-empty segment is `[A-Z0-9]+` (ASCII uppercase or digit). +/// There must be at least one segment after `CLA` (so `CLA-` alone is invalid). +/// +/// Examples of valid prefixes: `CLA-PY-`, `CLA-JAVA-`, `CLA-FOO-BAR-`. +/// Examples of invalid prefixes: `PY-`, `cla-py-`, `CLA-py-`, `CLA-PY` (no trailing +/// hyphen), `CLA-` (no segment after CLA), `CLA--PY-` (empty segment). +fn validate_rule_id_prefix_grammar(prefix: &str) -> Result<(), ManifestError> { + // Rule 1: must end with `-`. + let Some(without_trailing) = prefix.strip_suffix('-') else { + return Err(ManifestError::GrammarViolation { + field: "rule_id_prefix", + value: prefix.to_owned(), + }); + }; + + // Rule 2: split on `-`; first segment must be `CLA`; all subsequent segments + // must be non-empty `[A-Z0-9]+`; at least one segment must follow `CLA`. + let segments: Vec<&str> = without_trailing.split('-').collect(); + + // First segment must be exactly `CLA`. + if segments.first().copied() != Some("CLA") { + return Err(ManifestError::GrammarViolation { + field: "rule_id_prefix", + value: prefix.to_owned(), + }); + } + + // There must be at least one segment after `CLA`. + if segments.len() < 2 { + return Err(ManifestError::GrammarViolation { + field: "rule_id_prefix", + value: prefix.to_owned(), + }); + } + + // Remaining segments must be non-empty `[A-Z0-9]+`. + for seg in &segments[1..] { + if seg.is_empty() + || !seg + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) + { + return Err(ManifestError::GrammarViolation { + field: "rule_id_prefix", + value: prefix.to_owned(), + }); + } + } + + Ok(()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── Fixtures ────────────────────────────────────────────────────────────── + + /// The canonical valid manifest fixture (mirrors the L5 schema in §2). + const VALID_MANIFEST: &str = r#" +[plugin] +name = "clarion-plugin-python" +plugin_id = "mockplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "clarion-plugin-python" +language = "python" +extensions = ["py"] + +[capabilities.runtime] +expected_max_rss_mb = 512 +expected_entities_per_file = 5000 +wardline_aware = true +reads_outside_project_root = false + +[ontology] +entity_kinds = ["function", "class", "module", "decorator"] +edge_kinds = ["imports", "calls", "decorates", "contains"] +rule_id_prefix = "CLA-PY-" +ontology_version = "0.1.0" +"#; + + // ── Positive: full parse ────────────────────────────────────────────────── + + #[test] + fn positive_parse_valid_manifest_all_fields_populated() { + let manifest = parse_manifest(VALID_MANIFEST.as_bytes()).unwrap(); + + // [plugin] + assert_eq!(manifest.plugin.name, "clarion-plugin-python"); + assert_eq!(manifest.plugin.plugin_id, "mockplugin"); + assert_eq!(manifest.plugin.version, "0.1.0"); + assert_eq!(manifest.plugin.protocol_version, "1.0"); + assert_eq!(manifest.plugin.executable, "clarion-plugin-python"); + assert_eq!(manifest.plugin.language, "python"); + assert_eq!(manifest.plugin.extensions, vec!["py"]); + + // [capabilities.runtime] + assert_eq!(manifest.capabilities.runtime.expected_max_rss_mb, 512); + assert_eq!( + manifest.capabilities.runtime.expected_entities_per_file, + 5000 + ); + assert!(manifest.capabilities.runtime.wardline_aware); + assert!(!manifest.capabilities.runtime.reads_outside_project_root); + + // [ontology] + assert_eq!( + manifest.ontology.entity_kinds, + vec!["function", "class", "module", "decorator"] + ); + assert_eq!( + manifest.ontology.edge_kinds, + vec!["imports", "calls", "decorates", "contains"] + ); + assert_eq!(manifest.ontology.rule_id_prefix, "CLA-PY-"); + assert_eq!(manifest.ontology.ontology_version, "0.1.0"); + } + + // ── Positive: core-reserved edge kind allowed in edge_kinds ────────────── + + #[test] + fn positive_core_reserved_edge_kind_in_edge_kinds_parses_successfully() { + // ADR-022 §Core owns: plugins bind to core semantics by listing a reserved + // edge kind; the parser must NOT reject it. + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = ["contains", "calls"] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let manifest = parse_manifest(toml.as_bytes()).unwrap(); + assert!( + manifest + .ontology + .edge_kinds + .contains(&"contains".to_owned()) + ); + assert!(manifest.ontology.edge_kinds.contains(&"calls".to_owned())); + } + + // ── Positive: [integrations.*] passthrough ──────────────────────────────── + + #[test] + fn positive_integrations_block_passthrough_does_not_error() { + // WP3's plugin.toml adds [integrations.wardline]; must parse without error. + let toml = r#" +[plugin] +name = "clarion-plugin-python" +plugin_id = "python" +version = "0.1.0" +protocol_version = "1.0" +executable = "clarion-plugin-python" +language = "python" +extensions = ["py"] + +[capabilities.runtime] +expected_max_rss_mb = 512 +expected_entities_per_file = 5000 +wardline_aware = true +reads_outside_project_root = false + +[ontology] +entity_kinds = ["function"] +edge_kinds = [] +rule_id_prefix = "CLA-PY-" +ontology_version = "0.1.0" + +[integrations.wardline] +min_version = "0.1.0" +max_version = "1.0.0" +"#; + let manifest = parse_manifest(toml.as_bytes()).unwrap(); + // The integrations table is present; the core does not interpret it. + assert!(manifest.integrations.contains_key("wardline")); + } + + // ── Negative: too many [integrations.*] entries ─────────────────────────── + + /// A `plugin.toml` with more than [`MAX_INTEGRATIONS`] entries is rejected + /// as `Malformed` — prevents an attacker-controlled manifest from forcing + /// the host to retain an unbounded `BTreeMap` for + /// the run lifetime. + #[test] + fn negative_integrations_above_cap_is_rejected() { + use std::fmt::Write; + let mut toml = String::from( + r#"[plugin] +name = "clarion-plugin-x" +plugin_id = "x" +version = "0.1.0" +protocol_version = "1.0" +executable = "clarion-plugin-x" +language = "x" +extensions = ["x"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-X-" +ontology_version = "0.1.0" + +"#, + ); + for i in 0..=MAX_INTEGRATIONS { + write!(toml, "[integrations.entry_{i}]\nk = \"v\"\n\n").unwrap(); + } + let err = parse_manifest(toml.as_bytes()) + .expect_err("manifest with > MAX_INTEGRATIONS integrations must be rejected"); + assert!( + matches!(err, ManifestError::Malformed { ref message } + if message.contains("integrations") && message.contains("maximum")), + "expected Malformed integrations-cap error; got {err:?}" + ); + } + + // ── Positive: plugin_id can differ from name ────────────────────────────── + + #[test] + fn positive_plugin_id_can_differ_from_name() { + // Verifies that [plugin].name (hyphens OK) and plugin_id (kind grammar) + // are independently valid. This is the exact case that caused the + // wp2/wp3 contradiction: name = "clarion-plugin-python" (hyphens) while + // the entity_id needed the segment "python". + let toml = r#" +[plugin] +name = "clarion-plugin-python" +plugin_id = "python" +version = "0.1.0" +protocol_version = "1.0" +executable = "clarion-plugin-python" +language = "python" +extensions = ["py"] + +[capabilities.runtime] +expected_max_rss_mb = 512 +expected_entities_per_file = 5000 +wardline_aware = true +reads_outside_project_root = false + +[ontology] +entity_kinds = ["function"] +edge_kinds = [] +rule_id_prefix = "CLA-PY-" +ontology_version = "0.1.0" +"#; + let manifest = parse_manifest(toml.as_bytes()).unwrap(); + assert_eq!(manifest.plugin.name, "clarion-plugin-python"); + assert_eq!(manifest.plugin.plugin_id, "python"); + } + + // ── Negative: missing plugin_id ─────────────────────────────────────────── + + #[test] + fn negative_missing_plugin_id_returns_malformed() { + // A manifest without [plugin].plugin_id must fail deserialization because + // plugin_id is a required field (no serde default). + let toml = r#" +[plugin] +name = "my-plugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!(err, ManifestError::Malformed { .. })); + } + + // ── Negative: plugin_id with hyphen rejected ────────────────────────────── + + #[test] + fn negative_plugin_id_with_hyphen_rejected_as_malformed() { + // "my-plugin" contains a hyphen; the ADR-022 kind grammar [a-z][a-z0-9_]* + // forbids it. This is the exact contradiction that motivated separating + // plugin_id from name. + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "my-plugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { + field: "plugin_id", + ref value, + } if value == "my-plugin" + )); + } + + // ── Negative: missing [plugin].name ────────────────────────────────────── + + #[test] + fn negative_missing_plugin_name_returns_malformed() { + let toml = r#" +[plugin] +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!(err, ManifestError::Malformed { .. })); + } + + // ── Negative: expected_max_rss_mb = 0 ──────────────────────────────────── + + #[test] + fn negative_zero_rss_mb_rejected() { + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 0 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!( + matches!(err, ManifestError::Malformed { message } if message.contains("expected_max_rss_mb")) + ); + } + + // ── Negative: entity_kinds = [] ────────────────────────────────────────── + + #[test] + fn negative_empty_entity_kinds_rejected() { + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = [] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!( + matches!(err, ManifestError::Malformed { message } if message.contains("entity_kinds")) + ); + } + + // ── Negative: malformed entity kind — uppercase ─────────────────────────── + + #[test] + fn negative_entity_kind_uppercase_is_grammar_violation() { + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["Function"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { field: "entity_kinds", value } if value == "Function" + )); + } + + #[test] + fn negative_entity_kind_hyphen_is_grammar_violation() { + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["func-tion"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { field: "entity_kinds", value } if value == "func-tion" + )); + } + + #[test] + fn negative_entity_kind_digit_prefix_is_grammar_violation() { + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["1function"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { field: "entity_kinds", value } if value == "1function" + )); + } + + // ── Negative: malformed rule_id_prefix ─────────────────────────────────── + + #[test] + fn negative_rule_id_prefix_no_cla_prefix_rejected() { + // "PY-" — does not start with CLA. + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "PY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { field: "rule_id_prefix", value } if value == "PY-" + )); + } + + #[test] + fn negative_rule_id_prefix_lowercase_rejected() { + // "cla-py-" — lowercase is invalid. + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "cla-py-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { field: "rule_id_prefix", value } if value == "cla-py-" + )); + } + + #[test] + fn negative_rule_id_prefix_mixed_case_segment_rejected() { + // "CLA-py-" — mixed-case segment after CLA. + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-py-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { field: "rule_id_prefix", value } if value == "CLA-py-" + )); + } + + // ── Negative: reserved entity kinds ────────────────────────────────────── + + #[test] + fn negative_reserved_entity_kind_file_rejected() { + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["file", "widget"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-RESERVED-KIND"); + assert!(matches!( + err, + ManifestError::ReservedKind { kind } if kind == "file" + )); + } + + #[test] + fn negative_reserved_entity_kind_subsystem_rejected() { + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["subsystem"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-RESERVED-KIND"); + assert!(matches!( + err, + ManifestError::ReservedKind { kind } if kind == "subsystem" + )); + } + + #[test] + fn negative_reserved_entity_kind_guidance_rejected() { + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["guidance"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-RESERVED-KIND"); + assert!(matches!( + err, + ManifestError::ReservedKind { kind } if kind == "guidance" + )); + } + + // ── Negative: reserved rule_id_prefix ──────────────────────────────────── + + #[test] + fn negative_reserved_prefix_cla_infra_rejected() { + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-INFRA-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-RULE-ID-NAMESPACE"); + assert!(matches!( + err, + ManifestError::ReservedPrefix { prefix } if prefix == "CLA-INFRA-" + )); + } + + #[test] + fn negative_reserved_prefix_cla_fact_rejected() { + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-FACT-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-RULE-ID-NAMESPACE"); + assert!(matches!( + err, + ManifestError::ReservedPrefix { prefix } if prefix == "CLA-FACT-" + )); + } + + // ── Negative: reads_outside_project_root = true (via validate_for_v0_1) ── + + #[test] + fn negative_reads_outside_project_root_flagged_by_validator() { + // The parser accepts this field faithfully; the validator rejects it. + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = true + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"#; + // parse_manifest must succeed — the parser does not reject this field. + let manifest = parse_manifest(toml.as_bytes()).unwrap(); + assert!(manifest.capabilities.runtime.reads_outside_project_root); + + // validate_for_v0_1 must surface the unsupported-capability error. + let err = manifest.validate_for_v0_1().unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY"); + assert!(matches!( + err, + ManifestError::UnsupportedCapability { + capability: "reads_outside_project_root" + } + )); + } + + // ── subcode coverage ────────────────────────────────────────────────────── + + #[test] + fn subcode_returns_correct_string_for_each_variant() { + assert_eq!( + ManifestError::Malformed { + message: String::new() + } + .subcode(), + "CLA-INFRA-MANIFEST-MALFORMED" + ); + assert_eq!( + ManifestError::GrammarViolation { + field: "entity_kinds", + value: String::new() + } + .subcode(), + "CLA-INFRA-MANIFEST-MALFORMED" + ); + assert_eq!( + ManifestError::ReservedKind { + kind: String::new() + } + .subcode(), + "CLA-INFRA-MANIFEST-RESERVED-KIND" + ); + assert_eq!( + ManifestError::ReservedPrefix { + prefix: String::new() + } + .subcode(), + "CLA-INFRA-RULE-ID-NAMESPACE" + ); + assert_eq!( + ManifestError::UnsupportedCapability { capability: "x" }.subcode(), + "CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY" + ); + } + + // ── Grammar edge cases ──────────────────────────────────────────────────── + + #[test] + fn negative_rule_id_prefix_no_trailing_hyphen_rejected() { + // "CLA-PY" — missing trailing `-`. + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-PY" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { + field: "rule_id_prefix", + .. + } + )); + } + + #[test] + fn negative_rule_id_prefix_empty_inner_segment_rejected() { + // "CLA--PY-" — empty segment between hyphens. + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA--PY-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { + field: "rule_id_prefix", + .. + } + )); + } + + #[test] + fn negative_rule_id_prefix_only_cla_rejected() { + // "CLA-" — no segment after CLA. + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-" +ontology_version = "0.1.0" +"#; + let err = parse_manifest(toml.as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { + field: "rule_id_prefix", + .. + } + )); + } + + #[test] + fn positive_multi_segment_rule_id_prefix_valid() { + // "CLA-FOO-BAR-" — valid multi-segment prefix. + let toml = r#" +[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = ["my"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-FOO-BAR-" +ontology_version = "0.1.0" +"#; + let manifest = parse_manifest(toml.as_bytes()).unwrap(); + assert_eq!(manifest.ontology.rule_id_prefix, "CLA-FOO-BAR-"); + } + + // ── Extension format grammar (ticket clarion-fa35cad487) ───────────────── + + fn ext_manifest(extensions_toml: &str) -> String { + format!( + r#"[plugin] +name = "my-plugin" +plugin_id = "myplugin" +version = "0.1.0" +protocol_version = "1.0" +executable = "my-plugin" +language = "mylang" +extensions = {extensions_toml} + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = [] +rule_id_prefix = "CLA-MY-" +ontology_version = "0.1.0" +"# + ) + } + + #[test] + fn positive_extension_lowercase_alphanumeric_accepted() { + let manifest = parse_manifest(ext_manifest(r#"["py"]"#).as_bytes()).unwrap(); + assert_eq!(manifest.plugin.extensions, vec!["py"]); + } + + #[test] + fn negative_extension_uppercase_rejected() { + let err = parse_manifest(ext_manifest(r#"["PY"]"#).as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { field: "extensions", value } if value == "PY" + )); + } + + #[test] + fn negative_extension_with_dot_rejected() { + let err = parse_manifest(ext_manifest(r#"[".py"]"#).as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { field: "extensions", value } if value == ".py" + )); + } + + #[test] + fn negative_extension_empty_string_rejected() { + let err = parse_manifest(ext_manifest(r#"[""]"#).as_bytes()).unwrap_err(); + assert_eq!(err.subcode(), "CLA-INFRA-MANIFEST-MALFORMED"); + assert!(matches!( + err, + ManifestError::GrammarViolation { field: "extensions", value } if value.is_empty() + )); + } +} diff --git a/crates/clarion-core/src/plugin/mock.rs b/crates/clarion-core/src/plugin/mock.rs new file mode 100644 index 00000000..974765eb --- /dev/null +++ b/crates/clarion-core/src/plugin/mock.rs @@ -0,0 +1,893 @@ +//! In-process mock plugin test harness. +//! +//! This module provides three canned plugin behaviours for use in unit tests +//! that exercise the transport and supervisor layers without spawning a real +//! subprocess: +//! +//! - [`MockPlugin::new_compliant`] — full JSON-RPC 2.0 handshake + `analyze_file`. +//! - [`MockPlugin::new_crashing`] — crashes (silently drops frames) after `initialized`. +//! - [`MockPlugin::new_oversize`] — responds to `initialize` with an oversized frame. +//! +//! # I/O model +//! +//! The mock exposes two I/O handles: +//! - [`MockPlugin::stdin`] — `&mut Vec` satisfying `impl Write`. The test +//! (acting as the core) calls [`write_frame`] into this sink. +//! - [`MockPlugin::stdout`] — `&mut Cursor>` satisfying `impl BufRead`. +//! The test calls [`read_frame`] from this source. +//! +//! After each batch of writes the test calls [`MockPlugin::tick`], which drains +//! the inbox, dispatches each frame according to `MockBehaviour`, and appends +//! response frames to the outbox. The test then reads from `stdout()`. +//! +//! # Oversize behaviour +//! +//! [`MockPlugin::new_oversize`] writes a frame whose `Content-Length` header +//! declares [`MOCK_OVERSIZE_BYTES`] (2 MiB) but whose actual body is only a +//! short placeholder. [`read_frame`]'s ceiling check fires before the body is +//! read, so the test does not need to allocate 2 MiB to trigger the error. + +use std::io::Cursor; + +use thiserror::Error; + +use super::{ + AnalyzeFileResult, InitializeResult, JsonRpcVersion, RequestEnvelope, ResponseEnvelope, + ResponsePayload, ShutdownResult, read_frame, write_frame, +}; +use crate::plugin::Frame; +use crate::plugin::limits::ContentLengthCeiling; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/// Content-Length value written by [`MockPlugin::new_oversize`]. +/// +/// 2 MiB — well above any realistic test ceiling (typically 64 KiB – 1 MiB). +/// The body bytes in the outbox are shorter; `read_frame`'s ceiling check fires +/// on the header value before the body is consumed. +pub const MOCK_OVERSIZE_BYTES: usize = 2 * 1024 * 1024; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Errors produced by [`MockPlugin::tick`]. +#[derive(Debug, Error)] +pub enum MockError { + /// Frame read/write failed. + #[error("transport error: {0}")] + Transport(#[from] super::TransportError), + + /// JSON serialisation/deserialisation failed. + #[error("serde error: {0}")] + Serde(#[from] serde_json::Error), + + /// A message arrived that the mock's protocol state machine did not expect + /// (e.g. an unknown method, or a message after the mock has exited). + #[error("protocol state violation: {0}")] + Protocol(String), +} + +// ── State machine ───────────────────────────────────────────────────────────── + +/// Internal lifecycle state of a [`MockPlugin`]. +/// +/// Transitions: +/// ```text +/// Fresh ──(initialize response sent)──► Initialized +/// Initialized ──(initialized notification received)──► Ready [Compliant] +/// ──► Crashed [Crashing] +/// Ready ──(shutdown response sent)──► ShutdownRequested +/// ShutdownRequested ──(exit notification received)──► Exited +/// * ──(exit notification received)──► Exited [shortcut] +/// Crashed ── all further frames silently dropped +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +enum MockState { + /// No frames exchanged yet. + Fresh, + /// `initialize` response has been sent; awaiting `initialized` notification. + Initialized, + /// `initialized` received; ready for `analyze_file`, `shutdown`, etc. + Ready, + /// Crashed after `initialized`; further frames are silently ignored. + Crashed, + /// `shutdown` response sent; awaiting `exit` notification. + ShutdownRequested, + /// `exit` received; no further frames are processed. + Exited, +} + +// ── Behaviour enum ──────────────────────────────────────────────────────────── + +/// Canned behaviour for a [`MockPlugin`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MockBehaviour { + /// Responds to every request with a well-formed result. + /// + /// For `analyze_file`, returns one entity with: + /// - `id`: `"mock:function:stub"` + /// - `kind`: `"function"` + /// - `qualified_name`: `"stub"` + /// - `source.file_path`: the path configured via + /// [`MockPlugin::set_compliant_entity_path`] (defaults to `"/tmp/stub.mock"` + /// which will fail the jail check in tests that use a `TempDir` project root; + /// call the setter before `analyze_file` to supply an in-root path). + Compliant, + + /// Responds to `initialize` normally; crashes after `initialized`. + /// + /// Any frame received after the `initialized` notification is silently + /// dropped — no response is produced. Simulates a subprocess that exited + /// post-handshake. + Crashing, + + /// Responds to `initialize` with a frame whose `Content-Length` declares + /// [`MOCK_OVERSIZE_BYTES`] but whose actual body is a short placeholder. + /// + /// `read_frame` with `max_bytes < MOCK_OVERSIZE_BYTES` returns + /// [`TransportError::FrameTooLarge`] without reading the body. + Oversize, + + /// Responds to `analyze_file` with one entity whose `kind` is `"unknown"`, + /// which is not declared in the mock's manifest `entity_kinds`. + /// + /// Used by T3 to verify the host drops undeclared-kind entities. + UndeclaredKind, + + /// Responds to `analyze_file` with one entity whose `id` field is + /// `"mock:function:stub"` but whose `kind` is `"function"` and + /// `qualified_name` is `"deliberately.wrong"` — so the expected id would + /// be `"mock:function:deliberately.wrong"` while the returned id says + /// `"mock:function:stub"`. + /// + /// Used by T4 to verify the host drops identity-mismatched entities. + IdMismatch, + + /// Responds to `analyze_file` with `escape_count` entities each having a + /// `source.file_path` of `"/tmp/escape_root_MOCK"` — a path that will + /// canonicalise outside any `TempDir`-based project root. + /// + /// Single escape count (1) → T5 (drop-not-kill). + /// Eleven or more → T6 (breaker trip). + EscapingPath(usize), +} + +// ── Mock plugin ─────────────────────────────────────────────────────────────── + +/// In-process mock plugin; stands in for a real subprocess during unit tests. +/// +/// See the [module-level documentation](self) for the I/O model and usage. +pub struct MockPlugin { + behaviour: MockBehaviour, + state: MockState, + /// Source path emitted in the `analyze_file` response entity for + /// [`MockBehaviour::Compliant`]. Set via [`set_compliant_entity_path`](Self::set_compliant_entity_path). + compliant_entity_path: String, + /// Bytes the core has written via [`write_frame`]; the mock reads here on + /// each [`tick`](Self::tick) call. + inbox: Vec, + /// Bytes the mock has produced; the core reads here via [`read_frame`]. + /// + /// `Cursor>` tracks a read position independently of the vec's + /// length, so appending to `outbox.get_mut()` does not disturb the + /// position — the core sees new bytes immediately on the next `read_frame` + /// call without a `set_position` reset. + outbox: Cursor>, +} + +impl MockPlugin { + // ── Constructors ────────────────────────────────────────────────────────── + + /// Creates a compliant mock that fully implements the plugin protocol. + pub fn new_compliant() -> Self { + Self::new(MockBehaviour::Compliant) + } + + /// Creates a mock that crashes after the `initialized` notification. + pub fn new_crashing() -> Self { + Self::new(MockBehaviour::Crashing) + } + + /// Creates a mock that responds to `initialize` with an oversized frame. + pub fn new_oversize() -> Self { + Self::new(MockBehaviour::Oversize) + } + + /// Creates a mock that responds to `analyze_file` with an entity whose + /// `kind` is `"unknown"` — not in the manifest's `entity_kinds`. + /// + /// Used by T3 (ontology-boundary enforcement). + pub fn new_undeclared_kind() -> Self { + Self::new(MockBehaviour::UndeclaredKind) + } + + /// Creates a mock that responds to `analyze_file` with an entity whose + /// `id` field does not match `entity_id(plugin_id, kind, qualified_name)`. + /// + /// Used by T4 (identity-mismatch check). + pub fn new_id_mismatch() -> Self { + Self::new(MockBehaviour::IdMismatch) + } + + /// Creates a mock that responds to `analyze_file` with `escape_count` + /// entities each having a `source.file_path` that canonicalises outside + /// the project root. + /// + /// Used by T5 (1 escape → drop-not-kill) and T6 (11 escapes → breaker trip). + pub fn new_escaping_path(escape_count: usize) -> Self { + Self::new(MockBehaviour::EscapingPath(escape_count)) + } + + fn new(behaviour: MockBehaviour) -> Self { + Self { + behaviour, + state: MockState::Fresh, + compliant_entity_path: "/tmp/stub.mock".to_owned(), + inbox: Vec::new(), + outbox: Cursor::new(Vec::new()), + } + } + + /// Override the `source.file_path` emitted by [`MockBehaviour::Compliant`] + /// in `analyze_file` responses. + /// + /// The default is `"/tmp/stub.mock"`, which lies outside any `TempDir`-based + /// project root and will fail the jail check. Call this after construction + /// and before `analyze_file` to supply a path that exists inside the test's + /// `project_root`. + pub fn set_compliant_entity_path(&mut self, path: impl Into) { + self.compliant_entity_path = path.into(); + } + + // ── I/O handles ─────────────────────────────────────────────────────────── + + /// Returns the inbox as a `&mut Vec`. + /// + /// `Vec` implements `Write`, so callers can pass this directly to + /// [`write_frame`]. + pub fn stdin(&mut self) -> &mut Vec { + &mut self.inbox + } + + /// Returns the outbox cursor as a `&mut Cursor>`. + /// + /// `Cursor>` implements `BufRead`, so callers can pass this + /// directly to [`read_frame`]. + pub fn stdout(&mut self) -> &mut Cursor> { + &mut self.outbox + } + + // ── Tick ────────────────────────────────────────────────────────────────── + + /// Drains the inbox, dispatches frames, and appends responses to the outbox. + /// + /// Call this after each batch of [`write_frame`] calls to the [`stdin`](Self::stdin) + /// handle. Any leftover bytes that do not form a complete frame are kept in + /// the inbox for the next tick. + /// + /// # Errors + /// + /// Returns [`MockError`] if a transport or serialisation error occurs. + /// Protocol-state violations (unknown method, message after exit) return + /// [`MockError::Protocol`]. + pub fn tick(&mut self) -> Result<(), MockError> { + // Steal the inbox bytes so we can parse them without borrowing issues. + let bytes = std::mem::take(&mut self.inbox); + let mut cursor = Cursor::new(bytes); + + loop { + // Peek at the remaining bytes to detect EOF without blocking. + // `cursor.position()` returns `u64`; on test hosts this always fits + // in `usize`, but we use `try_from` to satisfy clippy's + // `cast_possible_truncation` lint (which targets 32-bit targets). + let pos = usize::try_from(cursor.position()) + .expect("cursor position exceeds usize::MAX — impossible on any current target"); + if pos >= cursor.get_ref().len() { + // All bytes consumed. + break; + } + + // Try to read one complete frame. A `TruncatedBody` or EOF error + // means we have a partial frame — put the remaining bytes back and + // wait for the next tick. + let frame = match read_frame(&mut cursor, ContentLengthCeiling::unbounded()) { + Ok(f) => f, + Err(super::TransportError::TruncatedBody { .. }) => { + // Partial frame: put unconsumed bytes back into inbox. + let remaining = cursor.into_inner()[pos..].to_vec(); + self.inbox = remaining; + return Ok(()); + } + Err(super::TransportError::Io(e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + // Header section incomplete — partial frame. + let remaining = cursor.into_inner()[pos..].to_vec(); + self.inbox = remaining; + return Ok(()); + } + Err(e) => return Err(MockError::Transport(e)), + }; + + if let Err(e) = self.dispatch(&frame) { + // Preserve the failing frame and all subsequent bytes so that + // the caller can inspect or retry. `pos` is the start of the + // frame that failed dispatch — restore from there so the inbox + // contains the full failing frame plus anything queued behind it. + let inner = cursor.into_inner(); + self.inbox = inner[pos..].to_vec(); + return Err(e); + } + } + + Ok(()) + } + + // ── Internal dispatch ───────────────────────────────────────────────────── + + /// Dispatch one frame according to the current behaviour and state. + fn dispatch(&mut self, frame: &Frame) -> Result<(), MockError> { + // Exited mocks consume all frames silently. + if self.state == MockState::Exited { + return Ok(()); + } + // Crashed mocks consume all frames silently. + if self.state == MockState::Crashed { + return Ok(()); + } + + // Peek at the raw JSON to distinguish request (has `id`) from + // notification (no `id`). We do NOT use serde's typed envelopes for + // the peek because `#[serde(deny_unknown_fields)]` is absent on those + // types and we only need one field. + let raw: serde_json::Value = serde_json::from_slice(&frame.body)?; + + let has_id = raw.get("id").is_some_and(|v| !v.is_null()); + let method = raw + .get("method") + .and_then(|v| v.as_str()) + .ok_or_else(|| MockError::Protocol("frame missing 'method' field".into()))? + .to_owned(); + + if has_id { + // ── Request ─────────────────────────────────────────────────────── + let req: RequestEnvelope = serde_json::from_value(raw)?; + self.handle_request(&req)?; + } else { + // ── Notification ────────────────────────────────────────────────── + self.handle_notification(&method)?; + } + + Ok(()) + } + + fn handle_request(&mut self, req: &RequestEnvelope) -> Result<(), MockError> { + match req.method.as_str() { + "initialize" => self.respond_initialize(req.id), + "analyze_file" => self.respond_analyze_file(req.id), + "shutdown" => self.respond_shutdown(req.id), + other => Err(MockError::Protocol(format!( + "unknown method {other:?} in state {:?}", + self.state + ))), + } + } + + fn handle_notification(&mut self, method: &str) -> Result<(), MockError> { + match method { + "initialized" => { + // Transition state; no response produced. + match self.state { + MockState::Initialized => match self.behaviour { + MockBehaviour::Crashing => { + self.state = MockState::Crashed; + } + _ => { + self.state = MockState::Ready; + } + }, + _ => { + return Err(MockError::Protocol(format!( + "'initialized' notification received in unexpected state {:?}", + self.state + ))); + } + } + Ok(()) + } + "exit" => { + // Accepted in any living state; transitions to Exited. + self.state = MockState::Exited; + Ok(()) + } + other => Err(MockError::Protocol(format!( + "unknown notification {other:?} in state {:?}", + self.state + ))), + } + } + + // ── Response builders ───────────────────────────────────────────────────── + + fn respond_initialize(&mut self, id: i64) -> Result<(), MockError> { + if self.state != MockState::Fresh { + return Err(MockError::Protocol(format!( + "'initialize' received in unexpected state {:?}", + self.state + ))); + } + if self.behaviour == MockBehaviour::Oversize { + // Write a frame whose Content-Length declares MOCK_OVERSIZE_BYTES + // but whose body is a short placeholder. The ceiling check in + // read_frame fires before the body is consumed. + let placeholder = b"{}"; + let header = format!("Content-Length: {MOCK_OVERSIZE_BYTES}\r\n\r\n"); + self.outbox.get_mut().extend_from_slice(header.as_bytes()); + self.outbox.get_mut().extend_from_slice(placeholder); + // Stay in Fresh state — there is nothing more this mock will do. + Ok(()) + } else { + let result = InitializeResult { + name: "mock-plugin".into(), + version: "0.0.0".into(), + ontology_version: "0.0.0".into(), + capabilities: serde_json::json!({}), + }; + let env = ResponseEnvelope { + jsonrpc: JsonRpcVersion, + id, + payload: ResponsePayload::Result(serde_json::to_value(result)?), + }; + self.write_response(&env)?; + self.state = MockState::Initialized; + Ok(()) + } + } + + fn respond_analyze_file(&mut self, id: i64) -> Result<(), MockError> { + if self.state != MockState::Ready { + return Err(MockError::Protocol(format!( + "'analyze_file' request in unexpected state {:?}", + self.state + ))); + } + let entities = match &self.behaviour { + MockBehaviour::UndeclaredKind => { + // Entity with kind "unknown" — not in any compliant manifest's + // entity_kinds list. Used by T3. + vec![serde_json::json!({ + "id": "mock:unknown:stub", + "kind": "unknown", + "qualified_name": "stub", + "source": { "file_path": "/tmp/mock_source.mock" } + })] + } + MockBehaviour::IdMismatch => { + // Entity with kind="function" and qualified_name="deliberately.wrong" + // but id says "mock:function:stub" — mismatch. Used by T4. + vec![serde_json::json!({ + "id": "mock:function:stub", + "kind": "function", + "qualified_name": "deliberately.wrong", + "source": { "file_path": "/tmp/mock_source.mock" } + })] + } + MockBehaviour::EscapingPath(escape_count) => { + // `escape_count` entities each with a source.file_path pointing + // outside any TempDir project root. The path must actually exist + // on the filesystem for jail's canonicalize to resolve it (and + // then find it outside the root). We use "/tmp" which exists on + // all Linux systems. Each entity has a unique qualified_name so + // identity checks pass; the id is constructed correctly. + // + // For the identity check to pass (so we get to the jail check), + // the entity's id must equal entity_id("mock", "function", name). + let count = *escape_count; + (0..count) + .map(|i| { + let qname = format!("escape.entity{i}"); + let eid = format!("mock:function:{qname}"); + serde_json::json!({ + "id": eid, + "kind": "function", + "qualified_name": qname, + "source": { "file_path": "/tmp" } + }) + }) + .collect() + } + _ => { + // Compliant / Crashing / Oversize — emit a complete RawEntity so + // it can survive the host's validation pipeline. The `id` must + // equal entity_id("mock", "function", "stub") = "mock:function:stub". + let path = self.compliant_entity_path.clone(); + vec![serde_json::json!({ + "id": "mock:function:stub", + "kind": "function", + "qualified_name": "stub", + "source": { "file_path": path } + })] + } + }; + let result = AnalyzeFileResult { entities }; + let env = ResponseEnvelope { + jsonrpc: JsonRpcVersion, + id, + payload: ResponsePayload::Result(serde_json::to_value(result)?), + }; + self.write_response(&env) + } + + fn respond_shutdown(&mut self, id: i64) -> Result<(), MockError> { + if !matches!(self.state, MockState::Initialized | MockState::Ready) { + return Err(MockError::Protocol(format!( + "'shutdown' received in unexpected state {:?}", + self.state + ))); + } + let result = ShutdownResult {}; + let env = ResponseEnvelope { + jsonrpc: JsonRpcVersion, + id, + payload: ResponsePayload::Result(serde_json::to_value(result)?), + }; + self.write_response(&env)?; + self.state = MockState::ShutdownRequested; + Ok(()) + } + + /// Serialise `env` and append it to the outbox as a framed message. + fn write_response(&mut self, env: &ResponseEnvelope) -> Result<(), MockError> { + let body = serde_json::to_vec(env)?; + let frame = Frame { body }; + // Append to the outbox vec without disturbing the read position. + let mut tmp: Vec = Vec::new(); + write_frame(&mut tmp, &frame)?; + self.outbox.get_mut().extend_from_slice(&tmp); + Ok(()) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin::protocol::{make_notification, make_request}; + use crate::plugin::{ + AnalyzeFileParams, InitializeParams, InitializedNotification, ResponsePayload, + TransportError, + }; + + // ── Helper: write a framed envelope into the mock's stdin ───────────────── + + fn send_request(mock: &mut MockPlugin, method: &str, params: &P, id: i64) { + let env = make_request(method, params, id); + let body = serde_json::to_vec(&env).expect("serialise"); + write_frame(mock.stdin(), &Frame { body }).expect("write_frame"); + } + + fn send_notification(mock: &mut MockPlugin, method: &str, params: &P) { + let env = make_notification(method, params); + let body = serde_json::to_vec(&env).expect("serialise"); + write_frame(mock.stdin(), &Frame { body }).expect("write_frame"); + } + + // ── Mandatory test: compliant mock completes handshake ──────────────────── + + /// Spec-mandated test (Task 3, §required test). + /// + /// Verifies that `MockPlugin::new_compliant()` can complete an `initialize` + /// handshake through the real transport layer (`write_frame` / `read_frame`). + #[test] + fn compliant_mock_completes_handshake_through_transport() { + let mut mock = MockPlugin::new_compliant(); + + // Step 2+3: build initialize request and write it as a frame. + let params = InitializeParams { + protocol_version: "1.0".into(), + project_root: "/tmp/x".to_owned(), + }; + send_request(&mut mock, "initialize", ¶ms, 1); + + // Step 4: tick so the mock processes the inbox and writes a response. + mock.tick().expect("tick must succeed"); + + // Step 5: read the response frame. + let frame = read_frame(mock.stdout(), ContentLengthCeiling::new(1024 * 1024)) + .expect("read_frame must succeed"); + + // Step 6: deserialise as ResponseEnvelope. + let resp: ResponseEnvelope = + serde_json::from_slice(&frame.body).expect("deserialise ResponseEnvelope"); + + // Step 7: assert envelope fields. + assert_eq!(resp.id, 1, "response id must echo request id"); + assert_eq!(resp.jsonrpc, JsonRpcVersion, "jsonrpc must be '2.0'"); + assert!( + matches!(resp.payload, ResponsePayload::Result(_)), + "payload must be Result, not Error; got {:?}", + resp.payload + ); + + // Step 8: deserialise and assert InitializeResult fields. + let result_val = match resp.payload { + ResponsePayload::Result(v) => v, + ResponsePayload::Error(e) => panic!("unexpected error payload: {e:?}"), + }; + let init_result: InitializeResult = + serde_json::from_value(result_val).expect("deserialise InitializeResult"); + assert_eq!( + init_result.name, "mock-plugin", + "name must be 'mock-plugin', got {:?}", + init_result.name + ); + } + + // ── Recommended test 1: compliant mock returns one entity on analyze_file ── + + #[test] + fn compliant_mock_returns_one_entity_on_analyze_file() { + let mut mock = MockPlugin::new_compliant(); + + // Full handshake first. + send_request( + &mut mock, + "initialize", + &InitializeParams { + protocol_version: "1.0".into(), + project_root: "/tmp/x".to_owned(), + }, + 1, + ); + mock.tick().expect("tick after initialize"); + + // Drain the initialize response frame so the cursor is ready for more. + read_frame(mock.stdout(), ContentLengthCeiling::new(1024 * 1024)) + .expect("read initialize response"); + + // Send initialized notification; mock transitions to Ready. + send_notification(&mut mock, "initialized", &InitializedNotification {}); + mock.tick().expect("tick after initialized notification"); + + // Send analyze_file request. + send_request( + &mut mock, + "analyze_file", + &AnalyzeFileParams { + file_path: "src/lib.py".to_owned(), + }, + 2, + ); + mock.tick().expect("tick after analyze_file"); + + // Read the analyze_file response. + let frame = read_frame(mock.stdout(), ContentLengthCeiling::new(1024 * 1024)) + .expect("read analyze_file response"); + let resp: ResponseEnvelope = + serde_json::from_slice(&frame.body).expect("deserialise analyze_file ResponseEnvelope"); + + assert_eq!(resp.id, 2); + let result_val = match resp.payload { + ResponsePayload::Result(v) => v, + ResponsePayload::Error(e) => panic!("unexpected error: {e:?}"), + }; + let result: crate::plugin::AnalyzeFileResult = + serde_json::from_value(result_val).expect("deserialise AnalyzeFileResult"); + assert_eq!( + result.entities.len(), + 1, + "compliant mock must return exactly one entity; got {}", + result.entities.len() + ); + } + + // ── Recommended test 2: crashing mock produces no response after initialized + + #[test] + fn crashing_mock_produces_no_response_after_initialized() { + let mut mock = MockPlugin::new_crashing(); + + // Handshake: initialize request. + send_request( + &mut mock, + "initialize", + &InitializeParams { + protocol_version: "1.0".into(), + project_root: "/tmp/x".to_owned(), + }, + 1, + ); + mock.tick().expect("tick after initialize"); + + // Drain the initialize response. + let frame = read_frame(mock.stdout(), ContentLengthCeiling::new(1024 * 1024)) + .expect("read initialize response"); + let resp: ResponseEnvelope = serde_json::from_slice(&frame.body).unwrap(); + assert!(matches!(resp.payload, ResponsePayload::Result(_))); + + // Record the outbox position after the initialize response; no new bytes + // should appear after the crash. + let pos_after_init = mock.stdout().position(); + + // Send initialized notification — this triggers the crash transition. + send_notification(&mut mock, "initialized", &InitializedNotification {}); + mock.tick().expect("tick after initialized notification"); + + // Send analyze_file — should be silently dropped. + send_request( + &mut mock, + "analyze_file", + &AnalyzeFileParams { + file_path: "src/lib.py".to_owned(), + }, + 2, + ); + mock.tick() + .expect("tick after analyze_file (crashing mock)"); + + // The outbox must not have grown past the initialize response. + let pos_after_crash = mock.stdout().position(); + let outbox_len = mock.stdout().get_ref().len() as u64; + assert_eq!( + outbox_len, pos_after_init, + "crashing mock must not write any bytes after the initialize response; \ + outbox grew from {pos_after_init} to {outbox_len}" + ); + // Read position should not have advanced either (no new frames produced). + assert_eq!( + pos_after_crash, pos_after_init, + "cursor position must not advance past the initialize response" + ); + } + + // ── Recommended test 3: oversize mock triggers FrameTooLarge ───────────── + + #[test] + fn oversize_mock_triggers_frame_too_large() { + let mut mock = MockPlugin::new_oversize(); + + // Send initialize — the oversize mock will respond with a huge frame. + send_request( + &mut mock, + "initialize", + &InitializeParams { + protocol_version: "1.0".into(), + project_root: "/tmp/x".to_owned(), + }, + 1, + ); + mock.tick() + .expect("tick must succeed even for oversize mock"); + + // Read with a ceiling well below MOCK_OVERSIZE_BYTES. + let ceiling = 64 * 1024; // 64 KiB + let err = read_frame(mock.stdout(), ContentLengthCeiling::new(ceiling)) + .expect_err("read_frame must fail with FrameTooLarge"); + + assert!( + matches!( + err, + TransportError::FrameTooLarge { observed, ceiling: c } + if observed == MOCK_OVERSIZE_BYTES && c == ceiling + ), + "expected FrameTooLarge {{ observed: {MOCK_OVERSIZE_BYTES}, ceiling: {ceiling} }}, got: {err}" + ); + } + + // ── B3 test: double initialize is rejected ──────────────────────────────── + + #[test] + fn mock_rejects_double_initialize() { + // Sending initialize twice must trigger MockError::Protocol on the + // second tick because the mock is no longer in MockState::Fresh. + let mut mock = MockPlugin::new_compliant(); + + // First initialize — must succeed. + send_request( + &mut mock, + "initialize", + &InitializeParams { + protocol_version: "1.0".into(), + project_root: "/tmp/x".to_owned(), + }, + 1, + ); + mock.tick().expect("first initialize must succeed"); + // Drain the response. + read_frame(mock.stdout(), ContentLengthCeiling::new(1024 * 1024)) + .expect("read first initialize response"); + + // Second initialize — the mock is now Initialized, not Fresh. + send_request( + &mut mock, + "initialize", + &InitializeParams { + protocol_version: "1.0".into(), + project_root: "/tmp/x".to_owned(), + }, + 2, + ); + let err = mock.tick().expect_err("second initialize must fail"); + assert!( + matches!(err, MockError::Protocol(ref msg) if msg.contains("Initialized")), + "error must mention the unexpected state (Initialized); got: {err}" + ); + } + + // ── B4 test: inbox preserved after dispatch error ───────────────────────── + + #[test] + fn mock_tick_preserves_remaining_inbox_after_dispatch_error() { + // Arrange: compliant mock. Enqueue TWO frames in one batch: + // frame 1 — valid initialize (will succeed, transitions to Initialized) + // frame 2 — second initialize (will fail B3 state guard) + // tick() must return Err and the inbox must still hold frame 2's bytes. + let mut mock = MockPlugin::new_compliant(); + + // Build frame 1: valid initialize. + let init_params = InitializeParams { + protocol_version: "1.0".into(), + project_root: "/tmp/x".to_owned(), + }; + { + use crate::plugin::protocol::make_request; + let env = make_request("initialize", &init_params, 1); + let body = serde_json::to_vec(&env).expect("serialise frame 1"); + write_frame(mock.stdin(), &Frame { body }).expect("write frame 1"); + } + + // Build frame 2: second initialize (will trigger state guard). + { + use crate::plugin::protocol::make_request; + let env = make_request("initialize", &init_params, 2); + let body = serde_json::to_vec(&env).expect("serialise frame 2"); + write_frame(mock.stdin(), &Frame { body }).expect("write frame 2"); + } + + // Record approximate minimum byte length of one framed initialize message + // so we can assert the inbox is non-trivially non-empty. + let approx_frame_min = 10; // conservative: even a tiny frame has at least header + body + + // tick() — frame 1 succeeds, frame 2 errors. + let err = mock + .tick() + .expect_err("tick must error on double initialize"); + assert!( + matches!(err, MockError::Protocol(_)), + "expected Protocol error; got: {err}" + ); + + // Inbox must contain the remaining bytes (frame 2 was not dispatched but + // its bytes should have been preserved by the B4 fix). + // Note: after B3 state guard fires, the dispatch error returns before + // frame 2 is written to the outbox, so only frame 1's response appears. + assert!( + mock.inbox.len() >= approx_frame_min, + "inbox must still hold frame 2 bytes after dispatch error; \ + inbox.len() = {}", + mock.inbox.len() + ); + + // Outbox must contain exactly one response frame (the successful first + // initialize response). Read it to confirm. + let frame = read_frame(mock.stdout(), ContentLengthCeiling::new(1024 * 1024)) + .expect("must be able to read the first initialize response from outbox"); + let resp: ResponseEnvelope = + serde_json::from_slice(&frame.body).expect("deserialise first initialize response"); + assert_eq!( + resp.id, 1, + "outbox frame must be the first initialize response" + ); + assert!( + matches!(resp.payload, ResponsePayload::Result(_)), + "first initialize response must be a Result" + ); + + // No second frame in the outbox. + let no_frame = read_frame(mock.stdout(), ContentLengthCeiling::new(1024 * 1024)); + assert!( + no_frame.is_err(), + "outbox must contain exactly one frame, but a second was readable" + ); + } +} diff --git a/crates/clarion-core/src/plugin/mod.rs b/crates/clarion-core/src/plugin/mod.rs new file mode 100644 index 00000000..3542f308 --- /dev/null +++ b/crates/clarion-core/src/plugin/mod.rs @@ -0,0 +1,45 @@ +//! Plugin-host facade. +//! +//! Submodules are added per WP2 task: +//! - `manifest` — Task 1: `plugin.toml` parser + validator (L5, ADR-021/ADR-022). +//! - `protocol` — Task 2: JSON-RPC 2.0 typed envelopes + param/result structs (L4). +//! - `transport` — Task 2: LSP-style Content-Length framing (L4). +//! - `jail` — Task 4: path-jail enforcement (ADR-021 §2a). +//! - `limits` — Task 4: core-enforced ceilings and circuit-breakers (ADR-021 §2b–§2d). +//! - `discovery` — Task 5: `$PATH` scanning for `clarion-plugin-*` executables (L9, ADR-021 §L9). +//! - `host` — Task 6: plugin-host supervisor (ADR-021 §Layer 2, ADR-022, UQ-WP2-11). +//! - `breaker` — Task 7: crash-loop breaker (ADR-002 + UQ-WP2-10). + +pub mod breaker; +pub mod discovery; +pub mod host; +pub mod jail; +pub mod limits; +pub mod manifest; +#[cfg(test)] +pub(crate) mod mock; +pub mod protocol; +pub mod transport; + +pub use breaker::{CrashLoopBreaker, CrashLoopState, FINDING_DISABLED_CRASH_LOOP}; +pub use discovery::{DiscoveredPlugin, DiscoveryError, discover, discover_on_path}; +pub use host::{AcceptedEntity, HostError, HostFinding, PluginHost}; +pub use jail::{JailError, jail, jail_to_string}; +pub use limits::{ + BreakerState, CapExceeded, ContentLengthCeiling, DEFAULT_MAX_NOFILE, DEFAULT_MAX_NPROC, + DEFAULT_MAX_RSS_MIB, EntityCountCap, FINDING_DISABLED_PATH_ESCAPE, FINDING_ENTITY_CAP, + FINDING_FRAME_OVERSIZE, FINDING_OOM_KILLED, FINDING_PATH_ESCAPE, PathEscapeBreaker, + apply_prlimit_as, apply_prlimit_nofile_nproc, effective_rss_mib, +}; +pub use manifest::{Manifest, ManifestError, parse_manifest}; +// `make_notification` and `make_request` are intentionally omitted — +// they're `pub(crate)` because they panic on serde failure for a +// property (well-formed param types) that external callers cannot +// guarantee. External consumers should build envelopes directly and +// handle the serde error. +pub use protocol::{ + AnalyzeFileParams, AnalyzeFileResult, ExitNotification, InitializeParams, InitializeResult, + InitializedNotification, JsonRpcVersion, NotificationEnvelope, ProtocolError, RequestEnvelope, + ResponseEnvelope, ResponsePayload, ShutdownParams, ShutdownResult, +}; +pub use transport::{Frame, TransportError, read_frame, write_frame}; diff --git a/crates/clarion-core/src/plugin/protocol.rs b/crates/clarion-core/src/plugin/protocol.rs new file mode 100644 index 00000000..9ebbc97d --- /dev/null +++ b/crates/clarion-core/src/plugin/protocol.rs @@ -0,0 +1,696 @@ +//! JSON-RPC 2.0 protocol types for the Clarion plugin host. +//! +//! # Design choice: Option B — struct-per-method with manual dispatch +//! +//! Each method has its own typed `Params` and `Result` struct. A top-level +//! `IncomingMessage` enum dispatches on the presence of `"id"` vs `"method"` in +//! the raw JSON. This keeps the JSON-RPC 2.0 envelope (`jsonrpc`, `id`) cleanly +//! separate from the method-specific payload, matching the wire spec exactly: +//! +//! ```json +//! {"jsonrpc":"2.0","method":"initialize","params":{...},"id":1} +//! ``` +//! +//! Option A (tagged enum) would conflict with the outer envelope because +//! `#[serde(tag = "method", content = "params")]` embeds `method` inside the +//! `params` layer rather than at the top level where JSON-RPC 2.0 requires it. +//! +//! # Sprint 1 scope +//! +//! Only the five L4 methods are typed here: +//! - `initialize` (request/response) +//! - `initialized` (notification — no id, no response expected) +//! - `analyze_file` (request/response) +//! - `shutdown` (request/response — empty params and result) +//! - `exit` (notification — no id, no response expected) +//! +//! Entity typing for `AnalyzeFileResult.entities` is deferred to Task 6 +//! (ontology boundary enforcement). The field is `Vec` as a +//! deliberate placeholder. +//! +//! `IncomingMessage` covers only plugin-to-core traffic (responses and +//! notifications) because Sprint 1's walking skeleton is core-initiated only. +//! Full bidirectional dispatch is Task 6's concern. + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use serde_json::{Map, Value}; + +// ── JSON-RPC version wrapper ────────────────────────────────────────────────── + +/// Wrapper that (de)serialises to/from the literal string `"2.0"`. +/// +/// Serde impl rejects any other value with a descriptive error, catching +/// wire-format corruption early. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JsonRpcVersion; + +impl Serialize for JsonRpcVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str("2.0") + } +} + +impl<'de> Deserialize<'de> for JsonRpcVersion { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + if s == "2.0" { + Ok(JsonRpcVersion) + } else { + Err(de::Error::custom(format!( + "unsupported JSON-RPC version {s:?}; expected \"2.0\"" + ))) + } + } +} + +// ── Envelope types ──────────────────────────────────────────────────────────── + +/// JSON-RPC 2.0 request envelope (core → plugin). +/// +/// Wire shape: `{"jsonrpc":"2.0","method":"...","params":{...},"id":1}` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RequestEnvelope { + pub jsonrpc: JsonRpcVersion, + pub method: String, + pub params: Value, + pub id: i64, +} + +/// JSON-RPC 2.0 notification envelope (no `id`, no response expected). +/// +/// Wire shape: `{"jsonrpc":"2.0","method":"...","params":{...}}` +/// +/// Used for `initialized` and `exit`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct NotificationEnvelope { + pub jsonrpc: JsonRpcVersion, + pub method: String, + pub params: Value, +} + +/// JSON-RPC 2.0 response envelope (plugin → core). +/// +/// Wire shape (success): `{"jsonrpc":"2.0","result":{...},"id":1}` +/// Wire shape (error): `{"jsonrpc":"2.0","error":{...},"id":1}` +/// +/// The spec (§5) requires **exactly one** of `result`/`error`. This type +/// enforces that invariant during deserialisation: +/// - both present → error (so a misbehaving plugin can't hide an error by +/// also sending a result). +/// - neither present → error (so a malformed response doesn't silently +/// become an empty success). +/// +/// Serialisation emits only the matching key (`result` or `error`). +#[derive(Debug, Clone, PartialEq)] +pub struct ResponseEnvelope { + pub jsonrpc: JsonRpcVersion, + pub id: i64, + pub payload: ResponsePayload, +} + +/// The result-or-error payload inside a [`ResponseEnvelope`]. +/// +/// Serialisation/deserialisation of the enclosing envelope is custom +/// (see [`ResponseEnvelope`]) — do not add serde attributes here. +#[derive(Debug, Clone, PartialEq)] +pub enum ResponsePayload { + Result(Value), + Error(ProtocolError), +} + +impl Serialize for ResponseEnvelope { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + + // 3 fields: jsonrpc, id, and either "result" or "error". + let mut s = serializer.serialize_struct("ResponseEnvelope", 3)?; + s.serialize_field("jsonrpc", &self.jsonrpc)?; + s.serialize_field("id", &self.id)?; + match &self.payload { + ResponsePayload::Result(v) => s.serialize_field("result", v)?, + ResponsePayload::Error(e) => s.serialize_field("error", e)?, + } + s.end() + } +} + +impl<'de> Deserialize<'de> for ResponseEnvelope { + fn deserialize>(deserializer: D) -> Result { + // Read into a generic JSON object, then enforce JSON-RPC 2.0 §5 + // (exactly one of `result`/`error`). Going via `Map` + // lets us give clear errors for both the "both present" and "neither + // present" cases — `#[serde(flatten)]` over an externally-tagged + // enum silently drops the `error` branch when both are present. + let mut obj = Map::::deserialize(deserializer)?; + + // Required fields. + let jsonrpc_val = obj + .remove("jsonrpc") + .ok_or_else(|| de::Error::missing_field("jsonrpc"))?; + let jsonrpc: JsonRpcVersion = + serde_json::from_value(jsonrpc_val).map_err(de::Error::custom)?; + + let id_val = obj + .remove("id") + .ok_or_else(|| de::Error::missing_field("id"))?; + let id: i64 = serde_json::from_value(id_val).map_err(de::Error::custom)?; + + // Enforce exactly-one-of. + let result = obj.remove("result"); + let error = obj.remove("error"); + let payload = match (result, error) { + (Some(_), Some(_)) => { + return Err(de::Error::custom( + "response envelope must have exactly one of `result` or `error`, \ + but both were present", + )); + } + (None, None) => { + return Err(de::Error::custom( + "response envelope must have exactly one of `result` or `error`, \ + but neither was present", + )); + } + (Some(v), None) => ResponsePayload::Result(v), + (None, Some(e)) => ResponsePayload::Error( + serde_json::from_value::(e).map_err(de::Error::custom)?, + ), + }; + + Ok(ResponseEnvelope { + jsonrpc, + id, + payload, + }) + } +} + +/// JSON-RPC 2.0 error object. +/// +/// `message` and `data` are truncated at deserialisation time — a plugin +/// returning multi-MiB error strings inside an 8 MiB frame would otherwise +/// balloon host RSS as the `ProtocolError` value is cloned through +/// `HostError::Protocol` and every `?`-propagation. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ProtocolError { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// Maximum accepted size of [`ProtocolError::message`] and the serialized +/// form of [`ProtocolError::data`] at the wire boundary. A well-behaved +/// plugin produces error messages well under 1 KiB; the 4 KiB cap matches +/// [`crate::plugin::host::MAX_ENTITY_FIELD_BYTES`] and lets a host-emitted +/// finding safely embed the message without further truncation. +pub const MAX_PROTOCOL_ERROR_FIELD_BYTES: usize = 4 * 1024; + +/// Truncate a string field to [`MAX_PROTOCOL_ERROR_FIELD_BYTES`]. Called on +/// every deserialisation of a plugin-originated `ProtocolError`. +fn truncate_for_display(s: String) -> String { + if s.len() <= MAX_PROTOCOL_ERROR_FIELD_BYTES { + return s; + } + // Slice on a char boundary at-or-before the cap. UTF-8 safety: walk + // backwards from the cap index until we find a boundary. + let mut end = MAX_PROTOCOL_ERROR_FIELD_BYTES; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + let mut out = String::with_capacity(end + 4); + out.push_str(&s[..end]); + out.push_str("..."); + out +} + +impl<'de> Deserialize<'de> for ProtocolError { + fn deserialize>(deserializer: D) -> Result { + // Go via an intermediate raw form so we can post-process each field + // before holding the full-size copy in the `ProtocolError` value. + #[derive(Deserialize)] + struct Raw { + code: i64, + message: String, + #[serde(default)] + data: Option, + } + let raw = Raw::deserialize(deserializer)?; + + // Truncate `message` to the cap. + let message = truncate_for_display(raw.message); + + // For `data`: if it's a string, truncate in place. Otherwise, cap + // its serialised byte length — a deeply-nested or wide `Value` + // tree at 8 MiB would otherwise survive past the frame ceiling. + let data = match raw.data { + None => None, + Some(Value::String(s)) => Some(Value::String(truncate_for_display(s))), + Some(v) => { + let bytes = serde_json::to_vec(&v).map_err(de::Error::custom)?; + if bytes.len() <= MAX_PROTOCOL_ERROR_FIELD_BYTES { + Some(v) + } else { + // Replace with a string-shaped marker preserving the + // shape-kind so operators can see "there was a data + // field but it was too large." + Some(Value::String(format!( + "", + bytes.len(), + MAX_PROTOCOL_ERROR_FIELD_BYTES, + ))) + } + } + }; + + Ok(ProtocolError { + code: raw.code, + message, + data, + }) + } +} + +// ── Method-level param and result structs ───────────────────────────────────── + +// ── initialize ──────────────────────────────────────────────────────────────── + +/// Params for `initialize` (core → plugin). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InitializeParams { + /// Protocol version the core speaks, e.g. `"1.0"`. + pub protocol_version: String, + /// Absolute path to the project root being analysed, as a UTF-8 string. + /// + /// Using `String` rather than `PathBuf` makes the wire format statically + /// UTF-8 safe. Task 4's jail owns the `PathBuf → String` conversion and + /// UTF-8 validation at the boundary. + pub project_root: String, +} + +/// Result for `initialize` (plugin → core). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct InitializeResult { + /// Plugin display name. + pub name: String, + /// Plugin version (semver). + pub version: String, + /// Ontology version (semver); used in ADR-007 cache keying. + pub ontology_version: String, + /// Opaque capability advertisement. Shape is left to the plugin; + /// the core forwards it without interpretation in Sprint 1. + pub capabilities: Value, +} + +// ── initialized ─────────────────────────────────────────────────────────────── + +/// Notification params for `initialized` (core → plugin). +/// +/// Deliberately empty: the notification carries no payload. The empty-braced +/// form (`struct Foo {}`) is intentional — it serialises to the JSON object +/// `{}` as JSON-RPC 2.0 §4.2 requires. A unit struct (`struct Foo;`) would +/// serialise to `null` and be rejected by strict decoders. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct InitializedNotification {} + +// ── analyze_file ────────────────────────────────────────────────────────────── + +/// Params for `analyze_file` (core → plugin). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AnalyzeFileParams { + /// Path to the file to analyse (relative or absolute; plugin resolves), + /// as a UTF-8 string. See [`InitializeParams::project_root`] for rationale. + pub file_path: String, +} + +/// Result for `analyze_file` (plugin → core). +/// +/// `entities` is `Vec` as a Sprint 1 placeholder. +/// Task 6 (ontology boundary enforcement) will introduce a typed `Entity` +/// struct and replace this field. The `Vec` shape matches the wire +/// contract without requiring Task 2 to know the entity schema. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AnalyzeFileResult { + /// Extracted entities. Element shape is the plugin's concern; the core + /// stores them opaquely until Task 6 introduces the typed ontology layer. + pub entities: Vec, +} + +// ── shutdown ────────────────────────────────────────────────────────────────── + +/// Params for `shutdown` (core → plugin). +/// +/// Empty by design — the message carries no payload. Empty-braced form is +/// intentional so the type serialises to JSON `{}` (an object), not `null`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShutdownParams {} + +/// Result for `shutdown` (plugin → core). +/// +/// JSON-RPC 2.0 requires a non-null response to a request, so we use an empty +/// result object `{}` rather than `null`. The empty-braced form ensures serde +/// emits `{}` (object) rather than `null` (which a unit struct would produce). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShutdownResult {} + +// ── exit ────────────────────────────────────────────────────────────────────── + +/// Notification params for `exit` (core → plugin). +/// +/// Deliberately empty: this is a forceful termination signal sent after the +/// plugin has replied to `shutdown`. No response is expected. Empty-braced +/// form ensures `{}` is emitted on the wire rather than `null`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ExitNotification {} + +// ── Helpers for building common envelopes ───────────────────────────────────── + +/// Convenience: build a `RequestEnvelope` from typed params. +/// +/// Kept `pub(crate)` because the body panics on `serde_json::to_value` failure +/// and the panic condition ("`params` serialises to a valid JSON value") is a +/// property of the internally-defined `ShutdownParams` / `InitializeParams` +/// / etc. structs, not something an arbitrary external caller could rely on. +/// External crates that need a `RequestEnvelope` should construct one +/// directly and surface the serde error themselves. +/// +/// # Panics +/// +/// Panics if serialisation of `params` fails. This should never happen for the +/// well-formed Sprint 1 param types. Callers that need explicit error handling +/// should construct [`RequestEnvelope`] directly. +pub(crate) fn make_request(method: &str, params: &P, id: i64) -> RequestEnvelope { + RequestEnvelope { + jsonrpc: JsonRpcVersion, + method: method.to_owned(), + params: serde_json::to_value(params).expect("params serialisation must not fail"), + id, + } +} + +/// Convenience: build a `NotificationEnvelope` from typed params. +/// +/// Kept `pub(crate)` for the same reason as [`make_request`] — the panic +/// condition is an internal-types property; external callers that want +/// a `NotificationEnvelope` should construct one directly. +/// +/// # Panics +/// +/// Panics if serialisation of `params` fails. This should never happen for the +/// well-formed Sprint 1 param types. Callers that need explicit error handling +/// should construct [`NotificationEnvelope`] directly. +pub(crate) fn make_notification(method: &str, params: &P) -> NotificationEnvelope { + NotificationEnvelope { + jsonrpc: JsonRpcVersion, + method: method.to_owned(), + params: serde_json::to_value(params).expect("params serialisation must not fail"), + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── Protocol test 1: round-trip InitializeParams ────────────────────────── + + #[test] + fn proto_01_initialize_params_round_trips() { + let params = InitializeParams { + protocol_version: "1.0".to_owned(), + project_root: "/home/user/project".to_owned(), + }; + let json = serde_json::to_string(¶ms).expect("serialise"); + let back: InitializeParams = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(params, back); + } + + // ── Protocol test 2: JsonRpcVersion rejects wrong versions ──────────────── + + #[test] + fn proto_02_json_rpc_version_rejects_non_2_0() { + // "3.0" + let bad_30 = r#"{"jsonrpc":"3.0","method":"initialize","params":{},"id":1}"#; + let err = serde_json::from_str::(bad_30); + assert!(err.is_err(), "should reject version 3.0"); + + // "1.0" + let bad_10 = r#"{"jsonrpc":"1.0","method":"initialize","params":{},"id":1}"#; + let err = serde_json::from_str::(bad_10); + assert!(err.is_err(), "should reject version 1.0"); + + // "" (empty) + let bad_empty = r#"{"jsonrpc":"","method":"initialize","params":{},"id":1}"#; + let err = serde_json::from_str::(bad_empty); + assert!(err.is_err(), "should reject empty version string"); + } + + // ── Protocol test 3: request envelope serialises to expected shape ───────── + + #[test] + fn proto_03_request_envelope_serialises_correctly() { + let params = InitializeParams { + protocol_version: "1.0".to_owned(), + project_root: "/proj".to_owned(), + }; + let env = make_request("initialize", ¶ms, 1); + let json = serde_json::to_string(&env).expect("serialise"); + let v: Value = serde_json::from_str(&json).expect("parse"); + + assert_eq!(v["jsonrpc"], "2.0", "jsonrpc field must be \"2.0\""); + assert_eq!(v["method"], "initialize", "method field"); + assert_eq!(v["id"], 1, "id field"); + // params object is present and contains protocol_version + assert_eq!(v["params"]["protocol_version"], "1.0"); + } + + // ── Protocol test 4: response envelope — both payload variants ──────────── + + #[test] + fn proto_04_response_envelope_result_variant_round_trips() { + let env = ResponseEnvelope { + jsonrpc: JsonRpcVersion, + id: 7, + payload: ResponsePayload::Result(serde_json::json!({"ok": true})), + }; + let json = serde_json::to_string(&env).expect("serialise"); + let v: Value = serde_json::from_str(&json).expect("parse"); + + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], 7); + assert_eq!(v["result"]["ok"], true); + assert!(v.get("error").is_none(), "no error key on success response"); + + // Full round-trip + let back: ResponseEnvelope = serde_json::from_str(&json).expect("deserialise back"); + assert_eq!(env, back); + } + + #[test] + fn proto_04_response_envelope_error_variant_round_trips() { + let env = ResponseEnvelope { + jsonrpc: JsonRpcVersion, + id: 7, + payload: ResponsePayload::Error(ProtocolError { + code: -32_600, + message: "Invalid Request".to_owned(), + data: None, + }), + }; + let json = serde_json::to_string(&env).expect("serialise"); + let v: Value = serde_json::from_str(&json).expect("parse"); + + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], 7); + assert_eq!(v["error"]["code"], -32_600); + assert_eq!(v["error"]["message"], "Invalid Request"); + assert!(v.get("result").is_none(), "no result key on error response"); + + // Full round-trip + let back: ResponseEnvelope = serde_json::from_str(&json).expect("deserialise back"); + assert_eq!(env, back); + } + + // ── Protocol test 5: notification envelope has no id field ──────────────── + + #[test] + fn proto_05_notification_envelope_has_no_id_field() { + let env = make_notification("initialized", &serde_json::json!({})); + let json = serde_json::to_string(&env).expect("serialise"); + let v: Value = serde_json::from_str(&json).expect("parse"); + + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["method"], "initialized"); + assert!( + v.get("id").is_none(), + "notification must not carry an id field" + ); + } + + // ── Protocol test 6: round-trip for all 5 methods' param+result structs ─── + + #[test] + fn proto_06_all_method_param_result_structs_round_trip() { + // initialize params + let p = InitializeParams { + protocol_version: "1.0".to_owned(), + project_root: "/proj".to_owned(), + }; + let back: InitializeParams = + serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); + assert_eq!(p, back); + + // initialize result + let r = InitializeResult { + name: "clarion-plugin-python".to_owned(), + version: "0.1.0".to_owned(), + ontology_version: "0.1.0".to_owned(), + capabilities: serde_json::json!({"wardline_aware": true}), + }; + let back: InitializeResult = + serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap(); + assert_eq!(r, back); + + // initialized notification — round-trips through the derived serde impl + let n = InitializedNotification {}; + let back: InitializedNotification = + serde_json::from_str(&serde_json::to_string(&n).unwrap()).unwrap(); + assert_eq!(n, back); + + // analyze_file params + let p = AnalyzeFileParams { + file_path: "src/main.py".to_owned(), + }; + let back: AnalyzeFileParams = + serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); + assert_eq!(p, back); + + // analyze_file result + let r = AnalyzeFileResult { + entities: vec![serde_json::json!({"kind": "function", "name": "main"})], + }; + let back: AnalyzeFileResult = + serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap(); + assert_eq!(r, back); + + // shutdown params (empty) + let p = ShutdownParams {}; + let back: ShutdownParams = + serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); + assert_eq!(p, back); + + // shutdown result (empty) + let r = ShutdownResult {}; + let back: ShutdownResult = + serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap(); + assert_eq!(r, back); + + // exit notification — round-trips through the derived serde impl + let n = ExitNotification {}; + let back: ExitNotification = + serde_json::from_str(&serde_json::to_string(&n).unwrap()).unwrap(); + assert_eq!(n, back); + } + + // ── C1 regression: unit-like params serialise as `{}`, not `null` ───────── + + #[test] + fn proto_07_unit_notifications_serialise_as_empty_object_not_null() { + // Every empty-braced param/result struct must serialise to the JSON + // object `{}` (JSON-RPC 2.0 §4.2 requires params to be a structured + // value; `null` violates the spec and strict Python decoders reject + // it). + let v = serde_json::to_value(InitializedNotification {}).unwrap(); + assert_eq!(v, serde_json::json!({}), "InitializedNotification"); + + let v = serde_json::to_value(ShutdownParams {}).unwrap(); + assert_eq!(v, serde_json::json!({}), "ShutdownParams"); + + let v = serde_json::to_value(ShutdownResult {}).unwrap(); + assert_eq!(v, serde_json::json!({}), "ShutdownResult"); + + let v = serde_json::to_value(ExitNotification {}).unwrap(); + assert_eq!(v, serde_json::json!({}), "ExitNotification"); + } + + // ── C2 regression: exactly-one-of result/error enforced at deserialise ──── + + #[test] + fn proto_08_response_with_both_result_and_error_rejected() { + // A plugin sending both `result` and `error` in the same response is + // malformed (JSON-RPC 2.0 §5 requires exactly one). The supervisor + // must see a hard error, not a silent drop of the `error` half. + let raw = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {"ok": true}, + "error": {"code": -32600, "message": "oops"} + }); + let err = serde_json::from_value::(raw).expect_err("must reject"); + let msg = err.to_string(); + assert!( + msg.contains("both"), + "error message should mention `both` fields present; got: {msg}" + ); + } + + #[test] + fn proto_protocol_error_message_truncated_at_deserialise() { + // A plugin returning a pathological multi-MiB message must not + // balloon host RSS via the ProtocolError clone path. + let huge = "x".repeat(MAX_PROTOCOL_ERROR_FIELD_BYTES * 4); + let raw = serde_json::json!({ + "code": -32_600, + "message": huge, + }); + let err: ProtocolError = serde_json::from_value(raw).expect("must deserialise"); + assert!( + err.message.len() <= MAX_PROTOCOL_ERROR_FIELD_BYTES + 4, + "message must be truncated; got {} bytes", + err.message.len() + ); + assert!( + err.message.ends_with("..."), + "truncated message must end with ellipsis; got: {:?}", + &err.message[err.message.len().saturating_sub(10)..] + ); + } + + #[test] + fn proto_protocol_error_data_replaced_when_over_cap() { + // `data` is an arbitrary JSON Value; when its serialised form + // exceeds the cap, it's replaced with a marker string rather + // than the full payload. + let big_array: Vec = (0..2000).map(|i| format!("entry_{i}")).collect(); + let raw = serde_json::json!({ + "code": -32_600, + "message": "err", + "data": big_array, + }); + let err: ProtocolError = serde_json::from_value(raw).expect("must deserialise"); + match err.data { + Some(Value::String(s)) => { + assert!(s.starts_with(" panic!("data must be replaced with truncation marker; got {other:?}"), + } + } + + #[test] + fn proto_08_response_with_neither_result_nor_error_rejected() { + // A response with neither `result` nor `error` is structurally invalid. + // This used to silently become `ResponsePayload::Result(Null)` via the + // flatten-enum approach; now it produces a loud error. + let raw = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1 + }); + let err = serde_json::from_value::(raw).expect_err("must reject"); + let msg = err.to_string(); + assert!( + msg.contains("neither"), + "error message should mention neither field present; got: {msg}" + ); + } +} diff --git a/crates/clarion-core/src/plugin/transport.rs b/crates/clarion-core/src/plugin/transport.rs new file mode 100644 index 00000000..1ecfce5f --- /dev/null +++ b/crates/clarion-core/src/plugin/transport.rs @@ -0,0 +1,568 @@ +//! LSP-style Content-Length framing for the Clarion plugin transport. +//! +//! Each frame is a self-describing byte sequence: +//! +//! ```text +//! Content-Length: N\r\n +//! \r\n +//! +//! ``` +//! +//! The `Frame` type is body-only; `Content-Length` is derived from `body.len()` +//! at write time. The transport layer is deliberately protocol-agnostic: it +//! knows nothing about `initialize`, `analyze_file`, etc. That coupling lives +//! in the supervisor (Task 6), which composes [`Frame`] with the types in +//! [`super::protocol`]. +//! +//! # Size ceiling +//! +//! [`read_frame`] accepts a [`ContentLengthCeiling`] parameter (ADR-021 §2b). +//! If the `Content-Length` header exceeds the ceiling, [`TransportError::FrameTooLarge`] +//! is returned **without** consuming the body bytes from the reader — the +//! supervisor decides what to do (typically disconnect). +//! +//! # No async +//! +//! The framing layer is synchronous (`impl BufRead` / `impl Write`). Task 6 +//! wires it over subprocess `ChildStdin`/`ChildStdout`, which implement +//! `Read`/`Write` without requiring async at this layer. + +use std::io::{BufRead, ErrorKind, Write}; + +use thiserror::Error; + +use super::limits::ContentLengthCeiling; + +// ── Tunables ────────────────────────────────────────────────────────────────── + +/// Per-line ceiling for header parsing. +/// +/// Bounds memory consumption if a misbehaving plugin sends a header line with +/// no terminating LF. Matches nginx's default `large_client_header_buffers` +/// (8 KiB). Real `Content-Length` headers are ~30 bytes; this limit is +/// generous for `Content-Type` or other tolerated headers while still +/// slamming the door on a naïve denial-of-service attempt. +pub const MAX_HEADER_LINE_BYTES: usize = 8 * 1024; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Errors that can occur during frame read/write. +#[derive(Debug, Error)] +pub enum TransportError { + /// Underlying I/O error. + #[error("IO error while reading/writing frame: {0}")] + Io(#[from] std::io::Error), + + /// The header section ended without a `Content-Length` line. + #[error("missing Content-Length header")] + MissingContentLength, + + /// A `Content-Length` header was present but its value was not a valid + /// non-negative decimal integer. + #[error("malformed Content-Length header: {value:?}")] + InvalidContentLength { value: String }, + + /// The declared frame body exceeds the configured ceiling. + /// + /// The body bytes are **not** consumed from the reader; the supervisor + /// must decide whether to disconnect or drain. + #[error("frame exceeds ceiling: observed {observed} bytes, ceiling {ceiling}")] + FrameTooLarge { observed: usize, ceiling: usize }, + + /// The stream ended before the declared number of body bytes were available. + #[error("unexpected EOF in frame body; expected {expected} bytes, got {actual}")] + TruncatedBody { expected: usize, actual: usize }, + + /// A header line did not conform to the expected `Name: Value` shape. + #[error("malformed header line: {line:?}")] + MalformedHeader { line: String }, +} + +// ── Frame type ──────────────────────────────────────────────────────────────── + +/// A single framed message: the raw body bytes from one `Content-Length` block. +/// +/// `Content-Length` is not stored; it is derived from `body.len()` on write. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + /// The raw body bytes (typically UTF-8 JSON, but the transport does not + /// validate encoding). + pub body: Vec, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Read one LSP-style frame from `reader`. +/// +/// Protocol: +/// 1. Read header lines until a bare `\r\n` (blank line). Each header line is +/// capped at [`MAX_HEADER_LINE_BYTES`] to bound memory under malicious input. +/// 2. Extract `Content-Length: N` (case-insensitive header name). +/// 3. If `N > ceiling.get()`, return [`TransportError::FrameTooLarge`] without +/// consuming any body bytes (ADR-021 §2b). +/// 4. Read exactly `N` bytes into the body. Retries transparently on +/// `ErrorKind::Interrupted` (EINTR — e.g. SIGCHLD on a subprocess pipe). +/// 5. Return `Frame { body }`. +/// +/// Unknown headers are silently ignored (LSP tolerance — `Content-Type` etc.). +/// +/// # Errors +/// +/// See [`TransportError`] variants for the full list of failure modes. +pub fn read_frame( + reader: &mut impl BufRead, + ceiling: ContentLengthCeiling, +) -> Result { + let mut content_length: Option = None; + + // ── Step 1+2: read header lines ────────────────────────────────────────── + loop { + let line = read_bounded_line(reader)?; + + // EOF before blank line — caller's stream ended unexpectedly. + if line.is_empty() { + return Err(TransportError::Io(std::io::Error::new( + ErrorKind::UnexpectedEof, + "EOF in header section", + ))); + } + + // Blank line signals end of headers. + if line == "\r\n" || line == "\n" { + break; + } + + // Strip CRLF / LF terminator for parsing. + let trimmed = line.trim_end_matches(['\r', '\n']); + + // Ignore empty lines inside the header block (defensive). + if trimmed.is_empty() { + continue; + } + + // Split on the first colon — the header must have one. + let Some((name, value)) = trimmed.split_once(':') else { + return Err(TransportError::MalformedHeader { + line: trimmed.to_owned(), + }); + }; + // Strip whitespace on both sides: LSP permits `Content-Length: 42 \r\n` + // (trailing whitespace before CRLF). + let value = value.trim(); + + // Case-insensitive comparison per LSP spec. + if name.trim().eq_ignore_ascii_case("content-length") { + let n: usize = value + .parse() + .map_err(|_| TransportError::InvalidContentLength { + value: value.to_owned(), + })?; + content_length = Some(n); + } + // All other headers are silently ignored. + } + + // ── Step 3: ceiling check (ADR-021 §2b) ────────────────────────────────── + let length = content_length.ok_or(TransportError::MissingContentLength)?; + let max_bytes = ceiling.get(); + if length > max_bytes { + // Do NOT read any body bytes. + return Err(TransportError::FrameTooLarge { + observed: length, + ceiling: max_bytes, + }); + } + + // ── Step 4: read body ───────────────────────────────────────────────────── + // + // The manual loop (vs `read_exact`) is deliberate: it lets us surface + // `TruncatedBody { expected, actual }` with the actual byte count, which + // `read_exact`'s `UnexpectedEof` discards. `ErrorKind::Interrupted` + // (EINTR) is retried transparently, matching `read_exact`'s own contract. + let mut body = vec![0u8; length]; + let mut total_read = 0usize; + while total_read < length { + match reader.read(&mut body[total_read..]) { + Ok(0) => { + return Err(TransportError::TruncatedBody { + expected: length, + actual: total_read, + }); + } + Ok(n) => total_read += n, + // EINTR: retry by letting the loop iterate again (match arm is a + // no-op; the while condition re-checks `total_read < length`). + Err(e) if e.kind() == ErrorKind::Interrupted => {} + Err(e) => return Err(TransportError::Io(e)), + } + } + + Ok(Frame { body }) +} + +/// Read one line from `reader` with a byte cap of [`MAX_HEADER_LINE_BYTES`]. +/// +/// Returns the line including any trailing CRLF / LF, so callers can distinguish +/// a blank line (`"\r\n"`) from a real header. Returns an empty string on EOF. +/// +/// If the cap is reached without encountering `\n`, returns +/// [`TransportError::MalformedHeader`] — prevents a malicious plugin from +/// sending a multi-GB header line to exhaust host memory. +/// +/// Retries transparently on `ErrorKind::Interrupted`. +fn read_bounded_line(reader: &mut impl BufRead) -> Result { + let mut buf = Vec::::new(); + let mut remaining = MAX_HEADER_LINE_BYTES; + + loop { + if remaining == 0 { + // We read the full cap and never saw a newline — fail loudly. + return Err(TransportError::MalformedHeader { + line: format!("header line exceeded {MAX_HEADER_LINE_BYTES}-byte limit"), + }); + } + + // `fill_buf` exposes the BufRead's internal buffer so we can scan for + // `\n` without reading one byte at a time. + let available = match reader.fill_buf() { + Ok(b) => b, + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => return Err(TransportError::Io(e)), + }; + + // EOF. + if available.is_empty() { + // If we had partial data before EOF, treat as EOF (caller detects + // via empty result only when `buf` is empty; partial data means + // truncation, but the caller currently treats the empty-string + // return as EOF — partial data here still hits the EOF arm because + // we return `buf` as-is and it will be non-empty-but-not-line- + // terminated). For Sprint 1, empty on EOF suffices — the caller + // raises UnexpectedEof only when `buf.is_empty()`. + break; + } + + // Look for `\n` within the portion of `available` we're allowed to consume. + let take = available.len().min(remaining); + if let Some(nl_idx) = available[..take].iter().position(|&b| b == b'\n') { + let consume = nl_idx + 1; + buf.extend_from_slice(&available[..consume]); + reader.consume(consume); + break; + } + + // No newline in the allowed window — consume what we have and loop + // again, either to read more or to hit the cap on the next iteration. + buf.extend_from_slice(&available[..take]); + reader.consume(take); + remaining -= take; + } + + // Header lines are ASCII per LSP. We tolerate arbitrary bytes in `buf` + // here; a genuinely non-UTF-8 header will surface as `MalformedHeader` + // from the caller's colon-split step. + String::from_utf8(buf).map_err(|e| TransportError::MalformedHeader { + line: format!("header line is not valid UTF-8: {e}"), + }) +} + +/// Write one LSP-style frame to `writer`. +/// +/// Produces: +/// ```text +/// Content-Length: N\r\n +/// \r\n +/// +/// ``` +/// +/// Flushes the writer before returning. This ensures the frame is actually +/// sent on buffered writers (e.g. `BufWriter`, which the plugin +/// supervisor will use) — without the flush, each frame would buffer +/// indefinitely and the plugin would never see it, producing a silent deadlock. +/// +/// # Errors +/// +/// Returns [`TransportError::Io`] on write or flush failure. +pub fn write_frame(writer: &mut impl Write, frame: &Frame) -> Result<(), TransportError> { + let len = frame.body.len(); + write!(writer, "Content-Length: {len}\r\n\r\n")?; + writer.write_all(&frame.body)?; + writer.flush()?; + Ok(()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use std::io::{BufReader, BufWriter, Cursor, Read}; + + use super::*; + + // ── Transport test 1: round-trip a single frame ─────────────────────────── + + #[test] + fn transport_01_single_frame_round_trip() { + let body = b"{\"jsonrpc\":\"2.0\",\"method\":\"initialized\",\"params\":{}}".to_vec(); + let frame = Frame { body: body.clone() }; + + // Write + let mut buf: Vec = Vec::new(); + write_frame(&mut buf, &frame).expect("write_frame must succeed"); + + // Read back + let mut cursor = Cursor::new(buf); + let decoded = read_frame(&mut cursor, ContentLengthCeiling::unbounded()) + .expect("read_frame must succeed"); + + assert_eq!(decoded.body, body); + } + + // ── Transport test 2: exact Content-Length boundary ─────────────────────── + + #[test] + fn transport_02_exact_ceiling_boundary_succeeds() { + let body = b"hello".to_vec(); + let frame = Frame { body: body.clone() }; + let max = body.len(); // exactly at the ceiling + + let mut buf: Vec = Vec::new(); + write_frame(&mut buf, &frame).expect("write"); + + let mut cursor = Cursor::new(buf); + let decoded = read_frame(&mut cursor, ContentLengthCeiling::new(max)) + .expect("read at exact boundary must succeed"); + assert_eq!(decoded.body, body); + } + + // ── Transport test 3: Content-Length above ceiling — body not consumed ──── + + #[test] + fn transport_03_content_length_above_ceiling_returns_frame_too_large_without_consuming_body() { + let body = b"hello world".to_vec(); + let frame = Frame { body: body.clone() }; + let max = body.len() - 1; // one byte below declared length + + let mut buf: Vec = Vec::new(); + write_frame(&mut buf, &frame).expect("write"); + + // Record position after headers so we can assert the body was not consumed. + // The header is "Content-Length: 11\r\n\r\n" — 22 bytes for len=11. + let header_len = format!("Content-Length: {}\r\n\r\n", body.len()).len(); + + let mut cursor = Cursor::new(buf); + let err = read_frame(&mut cursor, ContentLengthCeiling::new(max)) + .expect_err("should fail with FrameTooLarge"); + + assert!( + matches!( + err, + TransportError::FrameTooLarge { + observed, + ceiling, + } if observed == body.len() && ceiling == max + ), + "unexpected error: {err}" + ); + + // Cursor position must be exactly at the start of the body, not past it. + let pos = + usize::try_from(cursor.position()).expect("cursor position fits in usize on test host"); + assert_eq!( + pos, header_len, + "body must not have been consumed; cursor should be at position {header_len}, got {pos}" + ); + } + + // ── Transport test 4: two back-to-back frames ───────────────────────────── + + #[test] + fn transport_04_two_consecutive_frames_round_trip() { + let body1 = + b"{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{},\"id\":1}".to_vec(); + let body2 = + b"{\"jsonrpc\":\"2.0\",\"method\":\"shutdown\",\"params\":{},\"id\":2}".to_vec(); + + let mut buf: Vec = Vec::new(); + write_frame( + &mut buf, + &Frame { + body: body1.clone(), + }, + ) + .expect("write 1"); + write_frame( + &mut buf, + &Frame { + body: body2.clone(), + }, + ) + .expect("write 2"); + + let mut cursor = Cursor::new(buf); + let f1 = read_frame(&mut cursor, ContentLengthCeiling::unbounded()).expect("read frame 1"); + let f2 = read_frame(&mut cursor, ContentLengthCeiling::unbounded()).expect("read frame 2"); + + assert_eq!(f1.body, body1, "first frame body mismatch"); + assert_eq!(f2.body, body2, "second frame body mismatch"); + } + + // ── Transport test 5: missing Content-Length ────────────────────────────── + + #[test] + fn transport_05_missing_content_length_returns_error() { + // Headers end without Content-Length. + let raw = b"X-Custom: stuff\r\n\r\n{\"key\":\"value\"}"; + let mut cursor = Cursor::new(raw.as_ref()); + let err = + read_frame(&mut cursor, ContentLengthCeiling::unbounded()).expect_err("should fail"); + assert!( + matches!(err, TransportError::MissingContentLength), + "expected MissingContentLength, got: {err}" + ); + } + + // ── Transport test 6: malformed Content-Length ──────────────────────────── + + #[test] + fn transport_06_malformed_content_length_returns_invalid_content_length() { + let raw = b"Content-Length: abc\r\n\r\n"; + let mut cursor = Cursor::new(raw.as_ref()); + let err = + read_frame(&mut cursor, ContentLengthCeiling::unbounded()).expect_err("should fail"); + assert!( + matches!( + err, + TransportError::InvalidContentLength { ref value } if value == "abc" + ), + "expected InvalidContentLength, got: {err}" + ); + } + + // ── Transport test 7: truncated body ────────────────────────────────────── + + #[test] + fn transport_07_truncated_body_returns_truncated_body_error() { + // Header says 10, body has only 5 bytes. + let raw = b"Content-Length: 10\r\n\r\nhello"; + let mut cursor = Cursor::new(raw.as_ref()); + let err = + read_frame(&mut cursor, ContentLengthCeiling::unbounded()).expect_err("should fail"); + assert!( + matches!( + err, + TransportError::TruncatedBody { + expected: 10, + actual: 5 + } + ), + "expected TruncatedBody, got: {err}" + ); + } + + // ── I3 regression: EINTR retry during body read ─────────────────────────── + + /// Reader wrapper that returns `ErrorKind::Interrupted` on the first + /// `read` call, then delegates to the inner reader. + /// + /// Wrapped in `BufReader` in the test to satisfy the `BufRead` bound. + struct InterruptOnceReader { + inner: R, + interrupted: bool, + } + + impl Read for InterruptOnceReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if !self.interrupted { + self.interrupted = true; + return Err(std::io::Error::new( + ErrorKind::Interrupted, + "simulated signal", + )); + } + self.inner.read(buf) + } + } + + #[test] + fn transport_08_eintr_during_body_read_is_retried_transparently() { + // Build a valid frame, wrap the stream in a reader that raises EINTR + // once, and assert the frame still decodes cleanly. + let body = b"hello world".to_vec(); + let mut buf: Vec = Vec::new(); + write_frame(&mut buf, &Frame { body: body.clone() }).expect("write"); + + let raw = Cursor::new(buf); + let flaky = InterruptOnceReader { + inner: raw, + interrupted: false, + }; + let mut reader = BufReader::new(flaky); + + let frame = read_frame(&mut reader, ContentLengthCeiling::unbounded()) + .expect("EINTR must be retried, not propagated"); + assert_eq!(frame.body, body); + } + + // ── I4 regression: write_frame flushes buffered writers ─────────────────── + + #[test] + fn transport_09_write_frame_flushes_buffered_writer() { + // Without the flush call in write_frame, a BufWriter wrapping a small + // inner sink would hold the frame bytes in its buffer until dropped + // — a silent deadlock for a live subprocess. + let body = b"{\"jsonrpc\":\"2.0\",\"method\":\"initialized\",\"params\":{}}".to_vec(); + let frame = Frame { body: body.clone() }; + + // Use an inner Vec wrapped in a Cursor so we can read its position + // through a shared reference via `into_inner()` after the BufWriter + // relinquishes the sink. + let sink: Vec = Vec::with_capacity(1024); + let mut bw = BufWriter::new(sink); + write_frame(&mut bw, &frame).expect("write_frame"); + + // After write_frame returns, the inner Vec must contain the whole + // frame — no residual bytes stuck in the BufWriter. + let inner = bw.into_inner().expect("BufWriter should have been flushed"); + + let expected_header = format!("Content-Length: {}\r\n\r\n", body.len()); + let mut expected = expected_header.into_bytes(); + expected.extend_from_slice(&body); + + assert_eq!( + inner, expected, + "write_frame must flush the BufWriter so the whole frame reaches the inner sink" + ); + } + + // ── I5 regression: header-line cap ──────────────────────────────────────── + + #[test] + fn transport_10_oversize_header_line_returns_malformed_header() { + // 16 KiB of 'a' with no `\n` — exceeds the 8 KiB header-line cap. + // Without the bound, read_line would try to allocate 16 KiB+ and (in + // the malicious case) GBs of host memory before returning. + let payload = vec![b'a'; 16 * 1024]; + let mut cursor = Cursor::new(payload); + let err = + read_frame(&mut cursor, ContentLengthCeiling::unbounded()).expect_err("should fail"); + assert!( + matches!(err, TransportError::MalformedHeader { ref line } if line.contains("8192") || line.contains(&format!("{MAX_HEADER_LINE_BYTES}"))), + "expected MalformedHeader with size hint, got: {err}" + ); + } + + // ── I6 regression: trailing whitespace in header values ─────────────────── + + #[test] + fn transport_11_content_length_with_trailing_whitespace_parses() { + // A header like `Content-Length: 5 \r\n` is valid LSP — the previous + // implementation trimmed leading but not trailing whitespace, causing + // InvalidContentLength("5 "). Must parse cleanly now. + let raw = b"Content-Length: 5 \r\n\r\nhello"; + let mut cursor = Cursor::new(raw.as_ref()); + let frame = read_frame(&mut cursor, ContentLengthCeiling::unbounded()) + .expect("must parse with trailing ws"); + assert_eq!(frame.body, b"hello"); + } +} diff --git a/crates/clarion-core/tests/fixtures/plugin.toml b/crates/clarion-core/tests/fixtures/plugin.toml new file mode 100644 index 00000000..ef1e0117 --- /dev/null +++ b/crates/clarion-core/tests/fixtures/plugin.toml @@ -0,0 +1,20 @@ +[plugin] +name = "clarion-plugin-fixture" +plugin_id = "fixture" +version = "0.1.0" +protocol_version = "1.0" +executable = "clarion-plugin-fixture" +language = "fixture" +extensions = ["mt"] + +[capabilities.runtime] +expected_max_rss_mb = 256 +expected_entities_per_file = 100 +wardline_aware = false +reads_outside_project_root = false + +[ontology] +entity_kinds = ["widget"] +edge_kinds = ["uses"] +rule_id_prefix = "CLA-FIXTURE-" +ontology_version = "0.1.0" diff --git a/crates/clarion-core/tests/fixtures/sample.mt b/crates/clarion-core/tests/fixtures/sample.mt new file mode 100644 index 00000000..3abde50e --- /dev/null +++ b/crates/clarion-core/tests/fixtures/sample.mt @@ -0,0 +1,2 @@ +# sample.mt — fixture file for the plugin-host subprocess test. +widget demo.sample {} diff --git a/crates/clarion-core/tests/host_subprocess.rs b/crates/clarion-core/tests/host_subprocess.rs new file mode 100644 index 00000000..a694a0e2 --- /dev/null +++ b/crates/clarion-core/tests/host_subprocess.rs @@ -0,0 +1,280 @@ +//! T1 — subprocess happy path integration test. +//! +//! Spawns the `clarion-plugin-fixture` binary via [`PluginHost::spawn`], +//! performs the full handshake, issues one `analyze_file` for a fixture file, +//! receives one entity, shuts down cleanly, and asserts exit code 0. +//! +//! The fixture binary is located at runtime by searching the Cargo target +//! directory. This is necessary because `CARGO_BIN_EXE_*` is only available +//! for binaries in the same crate; cross-crate binary resolution requires +//! either `-Z bindeps` (unstable) or a runtime search. + +use clarion_core::PluginHost; +use clarion_core::plugin::parse_manifest; + +/// Path to the fixture plugin.toml — embedded at compile time. +const FIXTURE_MANIFEST_BYTES: &[u8] = include_bytes!("fixtures/plugin.toml"); + +/// Locate the `clarion-plugin-fixture` binary in the Cargo target directory. +/// +/// Searches the standard Cargo output locations in order: +/// 1. `CARGO_BIN_EXE_clarion-plugin-fixture` env var (set by cargo nextest +/// when artifact deps are enabled — future use). +/// 2. `/debug/clarion-plugin-fixture` (default dev build). +/// 3. `/release/clarion-plugin-fixture` (release build). +/// +/// Panics with a clear message if the binary is not found. +fn fixture_binary_path() -> std::path::PathBuf { + // Check if an explicit path was provided (e.g. by a future artifact dep). + if let Ok(path) = std::env::var("CARGO_BIN_EXE_clarion-plugin-fixture") { + return std::path::PathBuf::from(path); + } + + // Locate the workspace target directory via CARGO_MANIFEST_DIR. + // CARGO_MANIFEST_DIR for an integration test is the crate's directory. + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // clarion-core is at crates/clarion-core; workspace root is ../../ + let workspace_root = manifest_dir + .parent() // crates/ + .and_then(|p| p.parent()) // workspace root + .expect("workspace root must exist"); + + // Try CARGO_TARGET_DIR override first, then the default `target/` directory. + let target_dir = std::env::var("CARGO_TARGET_DIR") + .map_or_else(|_| workspace_root.join("target"), std::path::PathBuf::from); + + for profile in &["debug", "release"] { + let candidate = target_dir.join(profile).join("clarion-plugin-fixture"); + if candidate.exists() { + return candidate; + } + } + + panic!( + "clarion-plugin-fixture binary not found. \ + Run `cargo build -p clarion-plugin-fixture` before running this test. \ + Searched in: {}", + target_dir.display() + ); +} + +/// Verify the fixture manifest parses correctly. +/// This catches schema mismatches before the subprocess test runs. +#[test] +fn fixture_manifest_parses_correctly() { + let manifest = parse_manifest(FIXTURE_MANIFEST_BYTES).expect("fixture manifest must parse"); + assert_eq!(manifest.plugin.plugin_id, "fixture"); + assert_eq!(manifest.ontology.entity_kinds, vec!["widget"]); + assert_eq!(manifest.ontology.rule_id_prefix, "CLA-FIXTURE-"); + assert!( + !manifest.capabilities.runtime.reads_outside_project_root, + "fixture manifest must not request reads_outside_project_root" + ); +} + +/// T1: subprocess happy path. +/// +/// Spawns the fixture plugin, completes the handshake, analyzes a real file, +/// receives one entity, shuts down, and asserts exit code 0. +#[test] +fn t1_subprocess_happy_path() { + // 1. Parse the fixture manifest. Leave `plugin.executable` as declared + // in the TOML (a bare basename); spawn validates it matches the + // discovered binary's basename. + let manifest = + parse_manifest(FIXTURE_MANIFEST_BYTES).expect("fixture plugin.toml must be valid"); + + // 2. Build a real project root containing the fixture sample file. + let project_dir = tempfile::TempDir::new().expect("create tempdir"); + let sample_path = project_dir.path().join("sample.mt"); + std::fs::write(&sample_path, b"widget demo.sample {}\n").expect("write sample.mt"); + + // 3. Spawn the plugin with the discovered binary path. + let exec = fixture_binary_path(); + let (mut host, mut child) = + PluginHost::spawn(manifest, project_dir.path(), &exec).expect("spawn must succeed"); + + // 5. Analyze the fixture file. + let entities = host + .analyze_file(&sample_path) + .expect("analyze_file must succeed"); + + // 6. Assert: exactly one entity. + assert_eq!( + entities.len(), + 1, + "fixture plugin must return exactly one entity per analyze_file; got {}", + entities.len() + ); + let entity = &entities[0]; + assert_eq!( + entity.kind, "widget", + "entity kind must be 'widget'; got {:?}", + entity.kind + ); + assert_eq!( + entity.id.as_str(), + "fixture:widget:demo.sample", + "entity id must be 'fixture:widget:demo.sample'; got {:?}", + entity.id.as_str() + ); + + // 7. Shut down cleanly. + host.shutdown().expect("shutdown must succeed"); + + // 8. Wait for the child and assert exit code 0. + let status = child.wait().expect("wait for child process"); + assert!( + status.success(), + "fixture plugin must exit with code 0; got: {status:?}" + ); + + // 9. No unexpected findings. + let findings = host.take_findings(); + assert!( + findings.is_empty(), + "no findings expected on happy path; got: {findings:?}" + ); +} + +/// T9: handshake failure on a subprocess that exits before responding +/// returns `Err` promptly — the host does not hang on a closed stdout. +/// +/// Points `executable` at `/bin/true` (or Windows equivalent), which exits +/// immediately. The host tries to read the initialize response from a closed +/// stdout and returns a transport error. +/// +/// **What this test asserts**: `spawn()` returns `Err` and the whole call +/// completes well under 5 s. That's strictly a "did we hang?" probe — it +/// does NOT directly verify the zombie-reap behaviour added in commit +/// 0fcc57f (that fix is covered by code review of `host.rs::spawn`'s +/// `if let Err(e) = host.handshake()` block). Direct zombie observation +/// requires walking `/proc`, which is Linux-only and brittle across kernel +/// versions. +/// +/// The earlier name `t9_handshake_failure_exits_cleanly_without_hanging` +/// overstated this — "exits cleanly" implied zombie-reap coverage. +#[test] +#[cfg(unix)] +fn t9_handshake_failure_on_immediate_exit_returns_err_promptly() { + let manifest = parse_manifest(FIXTURE_MANIFEST_BYTES).expect("fixture manifest must parse"); + + let project_dir = tempfile::TempDir::new().expect("tmpdir"); + + // Construct a symlink whose basename matches the manifest-declared + // `plugin.executable` (`clarion-plugin-fixture`) but whose target is + // `/bin/true`. This exits immediately without reading stdin, which is + // the handshake-failure mode we want to test. Pointing `spawn` at + // `/bin/true` directly would fail the basename-match check before + // forking, which tests a different property. + let stub_dir = tempfile::TempDir::new().expect("stub dir"); + let stub_exec = stub_dir.path().join("clarion-plugin-fixture"); + std::os::unix::fs::symlink("/bin/true", &stub_exec).expect("symlink /bin/true"); + + let start = std::time::Instant::now(); + let result = PluginHost::spawn(manifest, project_dir.path(), &stub_exec); + let elapsed = start.elapsed(); + + assert!( + result.is_err(), + "spawn must fail when executable exits before handshake response" + ); + // Sanity: the handshake-failure path must not block. If reap lost a + // waitpid, this would still return but a regression that swapped kill() + // or wait() for a blocking read on the closed pipe would hang here. + assert!( + elapsed < std::time::Duration::from_secs(5), + "handshake failure must return promptly; took {elapsed:?}" + ); +} + +/// T9b: `stderr_tail()` is wired on subprocess-backed hosts. The fixture +/// plugin does not write to stderr on the happy path, so the tail is +/// `Some("")` or `Some()`; the key assertion is that it's `Some` +/// (not `None`) — the drain thread is attached and reachable. `None` +/// after spawn would indicate the stderr ring was never installed. +#[test] +#[cfg(unix)] +fn t9b_stderr_tail_is_some_after_spawn() { + let manifest = parse_manifest(FIXTURE_MANIFEST_BYTES).expect("fixture manifest must parse"); + let project_dir = tempfile::TempDir::new().expect("tmpdir"); + let sample_path = project_dir.path().join("sample.mt"); + std::fs::write(&sample_path, b"widget demo.sample {}\n").expect("write sample.mt"); + + let exec = fixture_binary_path(); + let (mut host, mut child) = + PluginHost::spawn(manifest, project_dir.path(), &exec).expect("spawn must succeed"); + + // The tail must be Some — drain thread is wired. Content may vary + // (the fixture doesn't write to stderr on success paths, so empty + // is expected). + let tail = host.stderr_tail(); + assert!( + tail.is_some(), + "subprocess host must expose Some(stderr_tail); got None" + ); + + host.shutdown().expect("shutdown"); + let _ = child.wait(); +} + +/// T10: `PluginHost::spawn` refuses a manifest whose `plugin.executable` +/// contains a path separator. A compromised `plugin.toml` must not be +/// able to redirect execution to `/bin/sh`, `python3`, or a relative +/// traversal; the manifest field is required to be a bare basename +/// matching the PATH-discovered binary. +#[test] +#[cfg(unix)] +fn t10_manifest_executable_with_path_separator_is_refused() { + use clarion_core::HostError; + + let mut manifest = parse_manifest(FIXTURE_MANIFEST_BYTES).expect("fixture manifest must parse"); + manifest.plugin.executable = "/bin/sh".to_owned(); + + let project_dir = tempfile::TempDir::new().expect("tmpdir"); + let exec = fixture_binary_path(); + + let Err(err) = PluginHost::spawn(manifest, project_dir.path(), &exec) else { + panic!("spawn must refuse absolute-path manifest executable"); + }; + match err { + HostError::Spawn(msg) => { + assert!( + msg.contains("path separator"), + "spawn error must name the path-separator violation; got: {msg}" + ); + } + other => panic!("expected HostError::Spawn; got {other:?}"), + } +} + +/// T11: `PluginHost::spawn` refuses a manifest whose `plugin.executable` +/// basename does not match the PATH-discovered binary. Prevents a plugin +/// directory hosting two binaries from accidentally cross-wiring: the +/// host never runs a binary with a different name than the manifest +/// declares. +#[test] +#[cfg(unix)] +fn t11_manifest_executable_basename_mismatch_is_refused() { + use clarion_core::HostError; + + let mut manifest = parse_manifest(FIXTURE_MANIFEST_BYTES).expect("fixture manifest must parse"); + // Declare a basename that will not match the discovered binary. + manifest.plugin.executable = "clarion-plugin-other".to_owned(); + + let project_dir = tempfile::TempDir::new().expect("tmpdir"); + let exec = fixture_binary_path(); + + let Err(err) = PluginHost::spawn(manifest, project_dir.path(), &exec) else { + panic!("spawn must refuse basename mismatch"); + }; + match err { + HostError::Spawn(msg) => { + assert!( + msg.contains("does not match") && msg.contains("basename"), + "spawn error must name the basename mismatch; got: {msg}" + ); + } + other => panic!("expected HostError::Spawn; got {other:?}"), + } +} diff --git a/crates/clarion-plugin-fixture/Cargo.toml b/crates/clarion-plugin-fixture/Cargo.toml new file mode 100644 index 00000000..21dfca74 --- /dev/null +++ b/crates/clarion-plugin-fixture/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "clarion-plugin-fixture" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "clarion-plugin-fixture" +path = "src/main.rs" + +[dependencies] +clarion-core = { path = "../clarion-core", version = "0.1.0-dev" } +serde_json.workspace = true diff --git a/crates/clarion-plugin-fixture/src/lib.rs b/crates/clarion-plugin-fixture/src/lib.rs new file mode 100644 index 00000000..90375d49 --- /dev/null +++ b/crates/clarion-plugin-fixture/src/lib.rs @@ -0,0 +1,3 @@ +//! Binary-only crate. This lib target exists so Cargo resolves the crate +//! cleanly when it's listed as a workspace member; the actual plugin logic +//! lives in `src/main.rs`. diff --git a/crates/clarion-plugin-fixture/src/main.rs b/crates/clarion-plugin-fixture/src/main.rs new file mode 100644 index 00000000..d1773234 --- /dev/null +++ b/crates/clarion-plugin-fixture/src/main.rs @@ -0,0 +1,126 @@ +//! Minimal test-fixture plugin binary. +//! +//! Speaks the Clarion JSON-RPC 2.0 protocol over `stdin`/`stdout`. Used by +//! the subprocess integration test (`host_subprocess.rs`) that exercises +//! `PluginHost::spawn`. +//! +//! Fixture identity: +//! - `plugin_id = "fixture"`, kind `"widget"`, rule-ID prefix `"CLA-FIXTURE-"` +//! - Responds to every `analyze_file` request with one entity: +//! `id = "fixture:widget:demo.sample"`, `kind = "widget"`, +//! `qualified_name = "demo.sample"`, `source.file_path` = the path sent in. + +use std::io::{BufReader, Write}; + +use clarion_core::plugin::limits::ContentLengthCeiling; +use clarion_core::plugin::transport::{Frame, read_frame, write_frame}; +use clarion_core::plugin::{ + AnalyzeFileParams, AnalyzeFileResult, InitializeResult, JsonRpcVersion, ResponseEnvelope, + ResponsePayload, ShutdownResult, +}; +use serde_json::Value; + +fn main() { + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + let mut reader = BufReader::new(stdin.lock()); + let mut writer = stdout.lock(); + + loop { + // Use the ADR-021 default (8 MiB) so this fixture has the same + // ceiling a real plugin sees. `unbounded()` is now `#[cfg(test)]` + // only — production code must name an explicit byte limit. + let Ok(frame) = read_frame(&mut reader, ContentLengthCeiling::DEFAULT) else { + std::process::exit(1) + }; + + let raw: Value = match serde_json::from_slice(&frame.body) { + Ok(v) => v, + Err(_) => std::process::exit(1), + }; + + let has_id = raw.get("id").is_some_and(|v| !v.is_null()); + let method = match raw.get("method").and_then(|v| v.as_str()) { + Some(m) => m.to_owned(), + None => std::process::exit(1), + }; + + if !has_id { + // Notification — no response required. + match method.as_str() { + "initialized" => { + // Transition to ready; no response. + } + "exit" => { + std::process::exit(0); + } + _ => std::process::exit(1), + } + continue; + } + + // Request — extract id. + let Some(id) = raw.get("id").and_then(serde_json::Value::as_i64) else { + std::process::exit(1) + }; + + match method.as_str() { + "initialize" => { + let result = InitializeResult { + name: "clarion-plugin-fixture".to_owned(), + version: "0.1.0".to_owned(), + ontology_version: "0.1.0".to_owned(), + capabilities: serde_json::json!({}), + }; + send_result(&mut writer, id, serde_json::to_value(result).unwrap()); + } + "analyze_file" => { + // Extract the file_path from params. + let file_path = raw + .get("params") + .and_then(|p| p.get("file_path")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + + let params: AnalyzeFileParams = match serde_json::from_value( + raw.get("params").cloned().unwrap_or(serde_json::json!({})), + ) { + Ok(p) => p, + Err(_) => std::process::exit(1), + }; + let _ = params; // we already extracted file_path + + let entity = serde_json::json!({ + "id": "fixture:widget:demo.sample", + "kind": "widget", + "qualified_name": "demo.sample", + "source": { + "file_path": file_path + } + }); + let result = AnalyzeFileResult { + entities: vec![entity], + }; + send_result(&mut writer, id, serde_json::to_value(result).unwrap()); + } + "shutdown" => { + let result = ShutdownResult {}; + send_result(&mut writer, id, serde_json::to_value(result).unwrap()); + } + _ => std::process::exit(1), + } + } +} + +fn send_result(writer: &mut impl Write, id: i64, result: Value) { + let env = ResponseEnvelope { + jsonrpc: JsonRpcVersion, + id, + payload: ResponsePayload::Result(result), + }; + let body = serde_json::to_vec(&env).expect("serialise response"); + let frame = Frame { body }; + write_frame(writer, &frame).expect("write frame"); + writer.flush().expect("flush"); +} diff --git a/crates/clarion-storage/Cargo.toml b/crates/clarion-storage/Cargo.toml new file mode 100644 index 00000000..f8626885 --- /dev/null +++ b/crates/clarion-storage/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "clarion-storage" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +clarion-core = { path = "../clarion-core", version = "0.1.0-dev" } +deadpool-sqlite.workspace = true +rusqlite.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/clarion-storage/migrations/0001_initial_schema.sql b/crates/clarion-storage/migrations/0001_initial_schema.sql new file mode 100644 index 00000000..56ad9fae --- /dev/null +++ b/crates/clarion-storage/migrations/0001_initial_schema.sql @@ -0,0 +1,197 @@ +-- ============================================================================ +-- Clarion migration 0001 — initial schema. +-- +-- Source: docs/clarion/v0.1/detailed-design.md §3 (Storage Implementation). +-- Sprint 1 walking skeleton writes only to `entities` and `runs`, but every +-- table, FTS5 virtual table, trigger, generated column, and view is created +-- here so the full shape is frozen at L1-lock time. See ADR-011 for the +-- writer-actor + per-N-files transaction model this schema supports. +-- ============================================================================ + +BEGIN; + +-- Meta: migration tracking. Not in detailed-design §3 — it's the runner's own +-- bookkeeping table. Applied migrations append a row here; re-runs are no-ops. +CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL +); + +-- Entities +CREATE TABLE entities ( + id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + kind TEXT NOT NULL, + name TEXT NOT NULL, + short_name TEXT NOT NULL, + parent_id TEXT REFERENCES entities(id), + source_file_id TEXT REFERENCES entities(id), + source_byte_start INTEGER, + source_byte_end INTEGER, + source_line_start INTEGER, + source_line_end INTEGER, + properties TEXT NOT NULL, + content_hash TEXT, + summary TEXT, + wardline TEXT, + first_seen_commit TEXT, + last_seen_commit TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX ix_entities_last_seen_commit ON entities(last_seen_commit); +CREATE INDEX ix_entities_kind ON entities(kind); +CREATE INDEX ix_entities_plugin_kind ON entities(plugin_id, kind); +CREATE INDEX ix_entities_parent ON entities(parent_id); +CREATE INDEX ix_entities_source_file ON entities(source_file_id); +CREATE INDEX ix_entities_content_hash ON entities(content_hash); + +-- Tags (denormalised) +CREATE TABLE entity_tags ( + entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (entity_id, tag) +); +CREATE INDEX ix_entity_tags_tag ON entity_tags(tag); + +-- Edges. Deduped by (kind, from_id, to_id); see detailed-design.md §3 note. +CREATE TABLE edges ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + from_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, + to_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, + properties TEXT, + source_file_id TEXT REFERENCES entities(id), + source_byte_start INTEGER, + source_byte_end INTEGER, + UNIQUE (kind, from_id, to_id) +); +CREATE INDEX ix_edges_from_kind ON edges(from_id, kind); +CREATE INDEX ix_edges_to_kind ON edges(to_id, kind); +CREATE INDEX ix_edges_kind ON edges(kind); + +-- Findings +CREATE TABLE findings ( + id TEXT PRIMARY KEY, + tool TEXT NOT NULL, + tool_version TEXT NOT NULL, + run_id TEXT NOT NULL, + rule_id TEXT NOT NULL, + kind TEXT NOT NULL, + severity TEXT NOT NULL, + confidence REAL, + confidence_basis TEXT, + entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, + related_entities TEXT NOT NULL, + message TEXT NOT NULL, + evidence TEXT NOT NULL, + properties TEXT NOT NULL, + supports TEXT NOT NULL, + supported_by TEXT NOT NULL, + status TEXT NOT NULL, + suppression_reason TEXT, + filigree_issue_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX ix_findings_entity ON findings(entity_id); +CREATE INDEX ix_findings_rule ON findings(rule_id); +CREATE INDEX ix_findings_tool_rule ON findings(tool, rule_id); +CREATE INDEX ix_findings_run ON findings(run_id); +CREATE INDEX ix_findings_status ON findings(status); + +-- Summary cache +CREATE TABLE summary_cache ( + entity_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + prompt_template_id TEXT NOT NULL, + model_tier TEXT NOT NULL, + guidance_fingerprint TEXT NOT NULL, + summary_json TEXT NOT NULL, + cost_usd REAL NOT NULL, + tokens_input INTEGER NOT NULL, + tokens_output INTEGER NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (entity_id, content_hash, prompt_template_id, model_tier, guidance_fingerprint) +); + +-- Runs (provenance). Sprint 1 writes started_at/completed_at/config/stats/status; +-- WP2 will populate plugin-invocation fields inside `config` JSON (per UQ-WP1-05). +CREATE TABLE runs ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + completed_at TEXT, + config TEXT NOT NULL, + stats TEXT NOT NULL, + status TEXT NOT NULL +); + +-- FTS5 for text search +CREATE VIRTUAL TABLE entity_fts USING fts5( + entity_id UNINDEXED, + name, + short_name, + summary_text, + content_text, + tokenize = 'porter unicode61' +); + +-- FTS5 triggers keep entity_fts synchronised with entities. +CREATE TRIGGER entities_ai AFTER INSERT ON entities BEGIN + INSERT INTO entity_fts (entity_id, name, short_name, summary_text, content_text) + VALUES ( + new.id, + new.name, + new.short_name, + COALESCE(json_extract(new.summary, '$.briefing.purpose'), ''), + '' + ); +END; +CREATE TRIGGER entities_au AFTER UPDATE ON entities BEGIN + UPDATE entity_fts + SET name = new.name, + short_name = new.short_name, + summary_text = COALESCE(json_extract(new.summary, '$.briefing.purpose'), '') + WHERE entity_id = new.id; +END; +CREATE TRIGGER entities_ad AFTER DELETE ON entities BEGIN + DELETE FROM entity_fts WHERE entity_id = old.id; +END; + +-- Generated columns + partial indexes for hot JSON properties. +ALTER TABLE entities ADD COLUMN priority TEXT + GENERATED ALWAYS AS (json_extract(properties, '$.priority')) VIRTUAL; +CREATE INDEX ix_entities_priority ON entities(priority) WHERE priority IS NOT NULL; + +ALTER TABLE entities ADD COLUMN git_churn_count INTEGER + GENERATED ALWAYS AS (json_extract(properties, '$.git_churn_count')) VIRTUAL; +CREATE INDEX ix_entities_churn ON entities(git_churn_count) WHERE git_churn_count IS NOT NULL; + +-- View for guidance resolver. detailed-design.md §3 references a bare `tags` +-- column on `entities` that does not exist under the normalised tag schema; +-- the view aggregates entity_tags via a correlated subquery to produce the +-- same JSON-array row shape the design implies. +CREATE VIEW guidance_sheets AS +SELECT + e.id, + e.name, + json_extract(e.properties, '$.priority') AS priority, + json_extract(e.properties, '$.scope.query_types') AS query_types, + json_extract(e.properties, '$.scope.token_budget') AS token_budget, + json_extract(e.properties, '$.match_rules') AS match_rules, + json_extract(e.properties, '$.content') AS content, + json_extract(e.properties, '$.expires') AS expires, + ( + SELECT json_group_array(tag) + FROM entity_tags + WHERE entity_id = e.id + ) AS tags +FROM entities e +WHERE e.kind = 'guidance'; + +-- Record the migration. +INSERT INTO schema_migrations (version, name, applied_at) +VALUES (1, '0001_initial_schema', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); + +COMMIT; diff --git a/crates/clarion-storage/src/commands.rs b/crates/clarion-storage/src/commands.rs new file mode 100644 index 00000000..3bb6654a --- /dev/null +++ b/crates/clarion-storage/src/commands.rs @@ -0,0 +1,105 @@ +//! Writer-actor command protocol (L3 lock-in). +//! +//! Per ADR-011, every persistent mutation is a `WriterCmd` variant. The +//! writer task owns the sole `rusqlite::Connection`; callers enqueue +//! commands via a bounded `mpsc::Sender`. Each variant carries +//! a `oneshot::Sender` for the per-command ack (UQ-WP1-03 resolution). +//! +//! Sprint 1 ships four variants: `BeginRun`, `InsertEntity`, `CommitRun`, +//! `FailRun`. Later WPs add `InsertEdge`, `InsertFinding`, etc. by appending +//! variants — the pattern is frozen here. + +use tokio::sync::oneshot; + +use crate::error::StorageError; + +pub type Ack = oneshot::Sender>; + +/// Run status values. Extended in later WPs; Sprint 1 uses only +/// `SkippedNoPlugins` (from `clarion analyze` without plugins wired) and +/// `Failed` (explicit `FailRun`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunStatus { + /// Sprint 1 stub: analyze invoked with no plugins registered. + SkippedNoPlugins, + /// Normal successful completion. + Completed, + /// Explicit failure via `FailRun`. + Failed, +} + +impl RunStatus { + pub fn as_str(self) -> &'static str { + match self { + RunStatus::SkippedNoPlugins => "skipped_no_plugins", + RunStatus::Completed => "completed", + RunStatus::Failed => "failed", + } + } +} + +/// Plain-old-data entity record as seen by the writer. Content-hash and +/// timestamps are supplied by callers; the writer does not compute them. +#[derive(Debug, Clone)] +pub struct EntityRecord { + pub id: String, + pub plugin_id: String, + pub kind: String, + pub name: String, + pub short_name: String, + pub parent_id: Option, + pub source_file_id: Option, + pub source_byte_start: Option, + pub source_byte_end: Option, + pub source_line_start: Option, + pub source_line_end: Option, + /// JSON string; writer inserts verbatim. + pub properties_json: String, + pub content_hash: Option, + pub summary_json: Option, + pub wardline_json: Option, + pub first_seen_commit: Option, + pub last_seen_commit: Option, + /// ISO-8601 UTC; writer inserts verbatim. + pub created_at: String, + pub updated_at: String, +} + +/// All writer operations as a single enum so the actor loop exhausts +/// everything via one match. +#[derive(Debug)] +pub enum WriterCmd { + /// Open a new run. The writer inserts a row into `runs` with status + /// `running`, begins an implicit transaction on the entities write + /// path, and binds `run_id` into its state. + BeginRun { + run_id: String, + config_json: String, + started_at: String, + ack: Ack<()>, + }, + /// Insert an entity; also advances the per-batch insert counter and + /// commits the in-flight transaction if the batch boundary is crossed. + InsertEntity { + entity: Box, + ack: Ack<()>, + }, + /// Commit the in-flight transaction, update the run row to the given + /// terminal status + `completed_at` + `stats_json`, and clear per-run + /// state. + CommitRun { + run_id: String, + status: RunStatus, + completed_at: String, + stats_json: String, + ack: Ack<()>, + }, + /// Roll back the in-flight transaction, update the run row to + /// `failed`, and clear per-run state. + FailRun { + run_id: String, + reason: String, + completed_at: String, + ack: Ack<()>, + }, +} diff --git a/crates/clarion-storage/src/error.rs b/crates/clarion-storage/src/error.rs new file mode 100644 index 00000000..d2b369f4 --- /dev/null +++ b/crates/clarion-storage/src/error.rs @@ -0,0 +1,42 @@ +//! Crate-local error type wrapping `rusqlite::Error` per UQ-WP1-06. + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum StorageError { + #[error("sqlite error: {0}")] + Sqlite(#[from] rusqlite::Error), + + #[error("connection-pool error: {0}")] + Pool(#[from] deadpool_sqlite::PoolError), + + #[error("pool build error: {0}")] + PoolBuild(#[from] deadpool_sqlite::CreatePoolError), + + #[error("pool interact error: {0}")] + PoolInteract(#[from] deadpool_sqlite::InteractError), + + #[error("PRAGMA invariant violated: {0}")] + PragmaInvariant(String), + + #[error("migration {version} failed: {source}")] + Migration { + version: u32, + #[source] + source: rusqlite::Error, + }, + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("channel closed — writer actor has exited")] + WriterGone, + + #[error("writer protocol violation: {0}")] + WriterProtocol(String), + + #[error("writer actor returned no response")] + WriterNoResponse, +} + +pub type Result = std::result::Result; diff --git a/crates/clarion-storage/src/lib.rs b/crates/clarion-storage/src/lib.rs new file mode 100644 index 00000000..98bb2bbe --- /dev/null +++ b/crates/clarion-storage/src/lib.rs @@ -0,0 +1,17 @@ +//! clarion-storage — `SQLite` layer, writer-actor, reader pool. +//! +//! All mutations route through the writer actor (a single `tokio::task` +//! owning the sole write `rusqlite::Connection`). Readers come from a +//! `deadpool-sqlite` pool. See ADR-011. + +pub mod commands; +pub mod error; +pub mod pragma; +pub mod reader; +pub mod schema; +pub mod writer; + +pub use commands::{EntityRecord, RunStatus, WriterCmd}; +pub use error::{Result, StorageError}; +pub use reader::ReaderPool; +pub use writer::{DEFAULT_BATCH_SIZE, DEFAULT_CHANNEL_CAPACITY, Writer}; diff --git a/crates/clarion-storage/src/pragma.rs b/crates/clarion-storage/src/pragma.rs new file mode 100644 index 00000000..29f1d2f6 --- /dev/null +++ b/crates/clarion-storage/src/pragma.rs @@ -0,0 +1,45 @@ +//! PRAGMAs applied at connection open per ADR-011 §`SQLite` PRAGMAs. + +use rusqlite::Connection; + +use crate::error::{Result, StorageError}; + +/// Apply the write-side PRAGMA set: WAL, `synchronous=NORMAL`, `busy_timeout`, +/// `wal_autocheckpoint`, `foreign_keys`. Called on the writer's connection once, +/// immediately after open. +/// +/// # Errors +/// +/// Returns [`crate::error::StorageError::Sqlite`] if any PRAGMA statement fails. +/// Returns [`crate::error::StorageError::PragmaInvariant`] if WAL mode is not +/// confirmed after the `PRAGMA journal_mode = WAL` command. +pub fn apply_write_pragmas(conn: &Connection) -> Result<()> { + let mode: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?; + if !mode.eq_ignore_ascii_case("wal") { + return Err(StorageError::PragmaInvariant(format!( + "expected WAL journal mode, got {mode:?} — \ + ADR-011's synchronous=NORMAL durability posture requires WAL" + ))); + } + conn.execute_batch(concat!( + "PRAGMA synchronous = NORMAL;", + "PRAGMA busy_timeout = 5000;", + "PRAGMA wal_autocheckpoint = 1000;", + "PRAGMA foreign_keys = ON;", + ))?; + Ok(()) +} + +/// Apply the read-side PRAGMA set: `busy_timeout` + `foreign_keys`. Readers do not +/// set `journal_mode` (WAL is a database-level mode set by the first writer). +/// +/// # Errors +/// +/// Returns [`crate::error::StorageError::Sqlite`] if any PRAGMA statement fails. +pub fn apply_read_pragmas(conn: &Connection) -> Result<()> { + conn.execute_batch(concat!( + "PRAGMA busy_timeout = 5000;", + "PRAGMA foreign_keys = ON;", + ))?; + Ok(()) +} diff --git a/crates/clarion-storage/src/reader.rs b/crates/clarion-storage/src/reader.rs new file mode 100644 index 00000000..72155e4e --- /dev/null +++ b/crates/clarion-storage/src/reader.rs @@ -0,0 +1,76 @@ +//! Read-only connection pool wrapping `deadpool-sqlite` per ADR-011. +//! +//! Readers take a connection from the pool, run a query, and drop it. The +//! pool caps concurrent connections (default 16 per ADR-011 §Reader pool). +//! WAL mode lets readers see the committed snapshot at the moment they +//! open; writes become visible only after the next checkpoint or a fresh +//! connection. + +use std::path::Path; + +use deadpool_sqlite::{Config, Pool, Runtime}; + +use crate::error::Result; +use crate::pragma; + +/// A read-only connection pool backed by `deadpool-sqlite`. +pub struct ReaderPool { + pool: Pool, +} + +impl ReaderPool { + /// Open a pool against an existing `SQLite` file. + /// + /// The database file must already exist and already have migrations + /// applied — callers should run [`crate::schema::apply_migrations`] on + /// a write connection first. + /// + /// # Errors + /// + /// Returns [`crate::StorageError::PoolBuild`] if `deadpool-sqlite` + /// cannot build the pool — typically because `max_size` is zero or + /// the runtime is not configured. The `SQLite` file itself is NOT + /// validated here; connections open lazily on the first + /// [`Self::with_reader`] call, and file-level errors (path missing, + /// permission denied) surface there instead. + pub fn open(db_path: impl AsRef, max_size: usize) -> Result { + let mut cfg = Config::new(db_path.as_ref()); + cfg.pool = Some(deadpool_sqlite::PoolConfig::new(max_size)); + let pool = cfg.create_pool(Runtime::Tokio1)?; + Ok(Self { pool }) + } + + /// Acquire a reader and run a blocking closure on it. + /// + /// Read-side PRAGMAs are applied on every acquisition — cheap and + /// guarantees `busy_timeout` + `foreign_keys` are always on. + /// + /// The closure must be `'static`: captures must be owned or cloned + /// into the closure (borrowed references from the caller's scope + /// will not compile). This is a consequence of `deadpool_sqlite`'s + /// `interact()` submitting the closure to a blocking task pool. + /// + /// # Errors + /// + /// Returns one of: + /// + /// - [`crate::StorageError::Pool`] if the pool cannot acquire a + /// connection (most commonly: pool exhausted, acquire timeout). + /// - [`crate::StorageError::PoolInteract`] if the closure panics or + /// the interact task is aborted. The pool recycles poisoned + /// connections automatically; subsequent calls remain usable. + /// - Whatever the closure itself returns on query failure (typically + /// [`crate::StorageError::Sqlite`]). + pub async fn with_reader(&self, f: F) -> Result + where + F: FnOnce(&rusqlite::Connection) -> Result + Send + 'static, + T: Send + 'static, + { + let obj = self.pool.get().await?; + obj.interact(move |conn| -> Result { + pragma::apply_read_pragmas(conn)?; + f(conn) + }) + .await? + } +} diff --git a/crates/clarion-storage/src/schema.rs b/crates/clarion-storage/src/schema.rs new file mode 100644 index 00000000..7814e782 --- /dev/null +++ b/crates/clarion-storage/src/schema.rs @@ -0,0 +1,109 @@ +//! Schema migration runner. +//! +//! Migrations are embedded at compile time via `include_str!`. On apply, each +//! is run if not already recorded in `schema_migrations`. Running twice is a +//! no-op. + +use rusqlite::{Connection, params}; + +use crate::error::{Result, StorageError}; + +struct Migration { + version: u32, + name: &'static str, + sql: &'static str, +} + +const MIGRATIONS: &[Migration] = &[Migration { + version: 1, + name: "0001_initial_schema", + sql: include_str!("../migrations/0001_initial_schema.sql"), +}]; + +/// Apply every migration not already recorded in `schema_migrations`. +/// +/// The first migration creates the `schema_migrations` table itself, so the +/// initial lookup tolerates its absence. +/// +/// # Errors +/// +/// Returns [`StorageError::Migration`] with the failing version on SQL error +/// during apply. Returns [`StorageError::Sqlite`] on bookkeeping failures. +pub fn apply_migrations(conn: &mut Connection) -> Result<()> { + let applied = read_applied_versions(conn)?; + for m in MIGRATIONS { + if applied.contains(&m.version) { + tracing::debug!(version = m.version, "migration already applied"); + continue; + } + apply_one(conn, m)?; + } + Ok(()) +} + +fn read_applied_versions(conn: &Connection) -> Result> { + let table_exists: Option = conn + .query_row( + "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'", + [], + |row| row.get(0), + ) + .ok(); + if table_exists.is_none() { + return Ok(Vec::new()); + } + let mut stmt = conn.prepare("SELECT version FROM schema_migrations ORDER BY version")?; + let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?; + let mut out = Vec::new(); + for row in rows { + let v: i64 = row?; + let v_u32 = u32::try_from(v).map_err(|_| StorageError::Migration { + version: 0, + source: rusqlite::Error::IntegralValueOutOfRange(0, v), + })?; + out.push(v_u32); + } + Ok(out) +} + +fn apply_one(conn: &mut Connection, m: &Migration) -> Result<()> { + tracing::info!(version = m.version, name = m.name, "applying migration"); + conn.execute_batch(m.sql) + .map_err(|source| StorageError::Migration { + version: m.version, + source, + })?; + // Defence in depth: the migration's own BEGIN/COMMIT has already committed, + // including its own INSERT INTO schema_migrations. This second statement + // handles only migrations that incorrectly omit their own record INSERT. + // INSERT OR IGNORE is a no-op when the version already exists (normal case). + conn.execute( + "INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) \ + VALUES (?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + params![i64::from(m.version), m.name], + )?; + Ok(()) +} + +/// Count of applied migrations (for tests + install). +/// +/// # Errors +/// +/// Returns [`StorageError::Sqlite`] if the query fails for reasons other than +/// the table not existing (in which case this returns `Ok(0)`). +pub fn applied_count(conn: &Connection) -> Result { + let table_exists: Option = conn + .query_row( + "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'", + [], + |row| row.get(0), + ) + .ok(); + if table_exists.is_none() { + return Ok(0); + } + let n: i64 = conn.query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| { + row.get(0) + })?; + Ok(u32::try_from(n).unwrap_or(u32::MAX)) +} diff --git a/crates/clarion-storage/src/writer.rs b/crates/clarion-storage/src/writer.rs new file mode 100644 index 00000000..5957cba5 --- /dev/null +++ b/crates/clarion-storage/src/writer.rs @@ -0,0 +1,367 @@ +//! Writer-actor implementation (L3 lock-in) per ADR-011. +//! +//! The actor owns the sole write `rusqlite::Connection`. Callers submit +//! commands via `Writer::sender()`. The actor loop pulls one command at a +//! time, applies the mutation inside an implicit transaction bound to the +//! current run, and commits every `batch_size` entity inserts (the +//! "per-N-files" transaction pattern, default N=50 per ADR-011). +//! +//! UQ-WP1-03 resolution: the `commits_observed` [`std::sync::Arc`]`<`[`std::sync::atomic::AtomicUsize`]`>` is +//! incremented on every `COMMIT` issued by the actor. Tests read it to +//! verify batch-boundary commits fire at the expected cadence. It is +//! present in release builds as a no-op counter; no `#[cfg(test)]` gating +//! is used. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use rusqlite::{Connection, params}; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; + +use crate::commands::{Ack, EntityRecord, RunStatus, WriterCmd}; +use crate::error::{Result, StorageError}; +use crate::pragma; + +/// Default transaction batch size per ADR-011. +pub const DEFAULT_BATCH_SIZE: usize = 50; + +/// Default `mpsc` channel capacity per ADR-011. +pub const DEFAULT_CHANNEL_CAPACITY: usize = 256; + +pub struct Writer { + tx: mpsc::Sender, + /// Count of every `COMMIT` statement issued by the actor. + /// + /// Includes both per-batch boundary commits (every `batch_size` inserts) + /// and the final commit issued by `CommitRun`. Intended for test + /// assertions and diagnostic counters; not a measure of completed runs. + /// + /// Read this field before dropping the [`Writer`]: the actor holds its + /// own `Arc` clone that lives until the `JoinHandle` resolves. + pub commits_observed: Arc, +} + +impl Writer { + /// Spawn the writer-actor on the current tokio runtime. + /// + /// Returns the `Writer` handle and the [`JoinHandle`] of the actor task. + /// Callers await the [`JoinHandle`] at shutdown to ensure the actor has + /// flushed any pending commit. + /// + /// # Errors + /// + /// 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, + batch_size: usize, + channel_capacity: usize, + ) -> Result<(Self, JoinHandle>)> { + let (tx, rx) = mpsc::channel(channel_capacity); + let commits_observed = Arc::new(AtomicUsize::new(0)); + let commits_for_actor = commits_observed.clone(); + let handle = tokio::task::spawn_blocking(move || -> Result<()> { + let mut conn = Connection::open(&db_path)?; + pragma::apply_write_pragmas(&conn)?; + run_actor(rx, &mut conn, batch_size, &commits_for_actor); + Ok(()) + }); + Ok(( + Writer { + tx, + commits_observed, + }, + handle, + )) + } + + pub fn sender(&self) -> mpsc::Sender { + self.tx.clone() + } + + /// Convenience: send a command and await its ack. + /// + /// Intended for use by `clarion analyze` (Task 7) and later WP + /// consumers; Sprint 1 integration tests use a local test helper + /// rather than this method. Kept as part of the L3 lock-in surface + /// so callers have a stable entry point when they arrive. + /// + /// # Errors + /// + /// Returns [`StorageError::WriterGone`] if the actor has exited and the + /// channel is closed. Returns [`StorageError::WriterNoResponse`] if the + /// actor dropped the `oneshot` sender without replying. Otherwise + /// propagates whatever error the actor returned for the command. + pub async fn send_wait(&self, build: F) -> Result + where + F: FnOnce(oneshot::Sender>) -> WriterCmd, + T: 'static, + { + let (tx, rx) = oneshot::channel(); + let cmd = build(tx); + self.tx + .send(cmd) + .await + .map_err(|_| StorageError::WriterGone)?; + rx.await.map_err(|_| StorageError::WriterNoResponse)? + } +} + +fn run_actor( + mut rx: mpsc::Receiver, + conn: &mut Connection, + batch_size: usize, + commits_observed: &AtomicUsize, +) { + let mut state = ActorState::new(batch_size); + + while let Some(cmd) = rx.blocking_recv() { + match cmd { + WriterCmd::BeginRun { + run_id, + config_json, + started_at, + ack, + } => { + reply( + ack, + begin_run(conn, &mut state, &run_id, &config_json, &started_at), + ); + } + WriterCmd::InsertEntity { entity, ack } => { + let res = insert_entity(conn, &mut state, &entity, commits_observed); + reply(ack, res); + } + WriterCmd::CommitRun { + run_id, + status, + completed_at, + stats_json, + ack, + } => { + let res = commit_run( + conn, + &mut state, + &run_id, + status, + &completed_at, + &stats_json, + commits_observed, + ); + reply(ack, res); + } + WriterCmd::FailRun { + run_id, + reason, + completed_at, + ack, + } => { + let res = fail_run(conn, &mut state, &run_id, &reason, &completed_at); + reply(ack, res); + } + } + } + // Channel closed. Best-effort cleanup. + // + // Two hazards to cover: an open entity transaction must be rolled back, + // and — if a run was in progress — its `runs.status` row must not be + // left permanently as `'running'`. We self-heal both. This path is + // reached when the Writer handle is dropped mid-run; once the normal + // CommitRun / FailRun flows are used, current_run is None here. + if state.in_tx { + let _ = conn.execute_batch("ROLLBACK"); + state.in_tx = false; + } + if let Some(run_id) = state.current_run.take() { + let stats_json = + serde_json::json!({ "failure_reason": "writer channel closed unexpectedly" }) + .to_string(); + let _ = conn.execute( + "UPDATE runs SET status = 'failed', \ + completed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), \ + stats = ?1 \ + WHERE id = ?2", + params![stats_json, run_id], + ); + } +} + +fn reply(ack: Ack, result: Result) { + // If the caller dropped the receiver, we discard the result. This is + // correct behaviour — the writer is still responsible for its own + // durability, and the caller chose to stop caring. + let _ = ack.send(result); +} + +struct ActorState { + batch_size: usize, + /// Inserts accumulated in the current transaction. + inserts_in_batch: usize, + /// True if `BEGIN` has been issued and no `COMMIT`/`ROLLBACK` has fired. + in_tx: bool, + /// The run currently in progress, if any. + current_run: Option, +} + +impl ActorState { + fn new(batch_size: usize) -> Self { + Self { + batch_size, + inserts_in_batch: 0, + in_tx: false, + current_run: None, + } + } +} + +fn begin_run( + conn: &mut Connection, + state: &mut ActorState, + run_id: &str, + config_json: &str, + started_at: &str, +) -> Result<()> { + if state.current_run.is_some() { + return Err(StorageError::WriterProtocol( + "BeginRun received while a run is already in progress".to_owned(), + )); + } + conn.execute( + "INSERT INTO runs (id, started_at, completed_at, config, stats, status) \ + VALUES (?1, ?2, NULL, ?3, '{}', 'running')", + params![run_id, started_at, config_json], + )?; + conn.execute_batch("BEGIN")?; + state.in_tx = true; + state.inserts_in_batch = 0; + state.current_run = Some(run_id.to_owned()); + Ok(()) +} + +fn insert_entity( + conn: &mut Connection, + state: &mut ActorState, + entity: &EntityRecord, + commits_observed: &AtomicUsize, +) -> Result<()> { + if state.current_run.is_none() { + return Err(StorageError::WriterProtocol( + "InsertEntity received without a preceding BeginRun".to_owned(), + )); + } + if !state.in_tx { + conn.execute_batch("BEGIN")?; + state.in_tx = true; + } + conn.execute( + "INSERT INTO entities ( \ + id, plugin_id, kind, name, short_name, \ + parent_id, source_file_id, \ + source_byte_start, source_byte_end, \ + source_line_start, source_line_end, \ + properties, content_hash, summary, wardline, \ + first_seen_commit, last_seen_commit, \ + created_at, updated_at \ + ) VALUES ( \ + ?1, ?2, ?3, ?4, ?5, \ + ?6, ?7, \ + ?8, ?9, \ + ?10, ?11, \ + ?12, ?13, ?14, ?15, \ + ?16, ?17, \ + ?18, ?19 \ + )", + params![ + entity.id, + entity.plugin_id, + entity.kind, + entity.name, + entity.short_name, + entity.parent_id, + entity.source_file_id, + entity.source_byte_start, + entity.source_byte_end, + entity.source_line_start, + entity.source_line_end, + entity.properties_json, + entity.content_hash, + entity.summary_json, + entity.wardline_json, + entity.first_seen_commit, + entity.last_seen_commit, + entity.created_at, + entity.updated_at, + ], + )?; + state.inserts_in_batch += 1; + if state.inserts_in_batch >= state.batch_size { + // State transitions BEFORE the fallible COMMIT: SQLite aborts the + // transaction on COMMIT failure regardless, so setting in_tx=false + // first keeps our state conservatively correct if the COMMIT errors. + state.inserts_in_batch = 0; + state.in_tx = false; + conn.execute_batch("COMMIT")?; + commits_observed.fetch_add(1, Ordering::Relaxed); + // Open the next batch eagerly so the next insert doesn't pay + // another `BEGIN` round-trip. + conn.execute_batch("BEGIN")?; + state.in_tx = true; + } + Ok(()) +} + +fn commit_run( + conn: &mut Connection, + state: &mut ActorState, + run_id: &str, + status: RunStatus, + completed_at: &str, + stats_json: &str, + commits_observed: &AtomicUsize, +) -> Result<()> { + // The run-row UPDATE and the final entity COMMIT must be atomic, otherwise + // a crash or SQL error between them would leave entities durable but + // `runs.status = 'running'` — indistinguishable from an in-progress run. + if state.in_tx { + // An entity batch is open: fold the UPDATE into it, then commit once. + conn.execute( + "UPDATE runs SET status = ?1, completed_at = ?2, stats = ?3 WHERE id = ?4", + params![status.as_str(), completed_at, stats_json, run_id], + )?; + state.in_tx = false; + conn.execute_batch("COMMIT")?; + commits_observed.fetch_add(1, Ordering::Relaxed); + } else { + // No entity batch open (e.g. SkippedNoPlugins path). A single-statement + // UPDATE is atomic under SQLite's implicit transaction. + conn.execute( + "UPDATE runs SET status = ?1, completed_at = ?2, stats = ?3 WHERE id = ?4", + params![status.as_str(), completed_at, stats_json, run_id], + )?; + } + state.current_run = None; + state.inserts_in_batch = 0; + Ok(()) +} + +fn fail_run( + conn: &mut Connection, + state: &mut ActorState, + run_id: &str, + reason: &str, + completed_at: &str, +) -> Result<()> { + if state.in_tx { + let _ = conn.execute_batch("ROLLBACK"); + state.in_tx = false; + } + let stats_json = serde_json::json!({ "failure_reason": reason }).to_string(); + conn.execute( + "UPDATE runs SET status = 'failed', completed_at = ?1, stats = ?2 WHERE id = ?3", + params![completed_at, stats_json, run_id], + )?; + state.current_run = None; + state.inserts_in_batch = 0; + Ok(()) +} diff --git a/crates/clarion-storage/tests/reader_pool.rs b/crates/clarion-storage/tests/reader_pool.rs new file mode 100644 index 00000000..6b1c5569 --- /dev/null +++ b/crates/clarion-storage/tests/reader_pool.rs @@ -0,0 +1,240 @@ +//! Reader-pool concurrency tests. + +use std::sync::Arc; + +use rusqlite::Connection; + +use clarion_storage::{ReaderPool, pragma, schema}; + +fn prepared_db(dir: &tempfile::TempDir) -> std::path::PathBuf { + let path = dir.path().join("clarion.db"); + let mut conn = Connection::open(&path).expect("open"); + pragma::apply_write_pragmas(&conn).expect("write pragmas"); + schema::apply_migrations(&mut conn).expect("migrate"); + path +} + +#[tokio::test] +async fn two_readers_run_concurrently() { + let dir = tempfile::tempdir().unwrap(); + let path = prepared_db(&dir); + let pool = Arc::new(ReaderPool::open(&path, 2).expect("pool")); + + let p1 = pool.clone(); + let p2 = pool.clone(); + let (a, b) = tokio::join!( + p1.with_reader(|conn| { + let n: i64 = conn.query_row("SELECT 1", [], |row| row.get(0))?; + Ok(n) + }), + p2.with_reader(|conn| { + let n: i64 = conn.query_row("SELECT 2", [], |row| row.get(0))?; + Ok(n) + }) + ); + assert_eq!(a.unwrap(), 1); + assert_eq!(b.unwrap(), 2); +} + +#[tokio::test] +async fn reader_sees_committed_data() { + let dir = tempfile::tempdir().unwrap(); + let path = prepared_db(&dir); + + // Pre-seed a runs row via a one-shot blocking connection. + { + let conn = Connection::open(&path).unwrap(); + pragma::apply_write_pragmas(&conn).unwrap(); + conn.execute( + "INSERT INTO runs (id, started_at, completed_at, config, stats, status) \ + VALUES (?1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), NULL, '{}', '{}', 'running')", + rusqlite::params!["run-1"], + ) + .unwrap(); + } + + let pool = ReaderPool::open(&path, 2).expect("pool"); + let status: String = pool + .with_reader(|conn| { + let status: String = + conn.query_row("SELECT status FROM runs WHERE id = 'run-1'", [], |row| { + row.get(0) + })?; + Ok(status) + }) + .await + .unwrap(); + assert_eq!(status, "running"); +} + +/// Gate for `pool_queues_when_exhausted_and_proceeds_after_release`. +/// +/// The first reader flips `acquired` once it holds the connection, then waits +/// on `cv_released` for the main task's release signal. Lets the test +/// synchronise precisely, without wall-clock guesses about "when has the +/// first reader probably acquired by now." +#[derive(Default)] +struct ReaderPoolGate { + acquired: std::sync::Mutex, + released: std::sync::Mutex, + cv_acquired: std::sync::Condvar, + cv_released: std::sync::Condvar, +} + +#[tokio::test] +async fn pool_queues_when_exhausted_and_proceeds_after_release() { + use tokio::time::{Duration, timeout}; + + let dir = tempfile::tempdir().unwrap(); + let path = prepared_db(&dir); + // max_size = 1 makes the exhaustion scenario trivial to construct. + let pool = Arc::new(ReaderPool::open(&path, 1).expect("pool")); + + let gate = Arc::new(ReaderPoolGate::default()); + + let pool_for_hold = pool.clone(); + let gate_for_hold = gate.clone(); + let held = tokio::spawn(async move { + pool_for_hold + .with_reader(move |conn| { + // Prove the connection was actually acquired. + let _: i64 = conn.query_row("SELECT 1", [], |row| row.get(0))?; + // Signal the main task that we hold the connection, then park + // (sync-wait) until it signals release. `.await` inside + // interact() is not permitted — this is a blocking thread. + { + let mut a = gate_for_hold.acquired.lock().unwrap(); + *a = true; + gate_for_hold.cv_acquired.notify_one(); + } + let mut r = gate_for_hold.released.lock().unwrap(); + while !*r { + r = gate_for_hold.cv_released.wait(r).unwrap(); + } + Ok::<_, clarion_storage::StorageError>(()) + }) + .await + }); + + // Wait deterministically for the first reader to acquire the connection. + // (A short timeout, but the gate signalling is precise — no wall-clock + // guess about scheduling.) + let gate_wait = gate.clone(); + tokio::task::spawn_blocking(move || { + let mut a = gate_wait.acquired.lock().unwrap(); + while !*a { + let (guard, _) = gate_wait + .cv_acquired + .wait_timeout(a, Duration::from_secs(2)) + .unwrap(); + a = guard; + if *a { + break; + } + } + assert!(*a, "first reader failed to acquire within 2s"); + }) + .await + .unwrap(); + + // Second reader: should block on the exhausted pool. Spawn it and assert + // it is NOT yet finished — if the pool mis-behaved and let two readers + // in concurrently, this would flake-pass before; now we catch it. + let pool_for_wait = pool.clone(); + let second_handle = tokio::spawn(async move { + pool_for_wait + .with_reader(|conn| { + let n: i64 = conn.query_row("SELECT 2", [], |row| row.get(0))?; + Ok(n) + }) + .await + }); + // Give the runtime a turn to schedule the second task; if it hasn't + // blocked by then, the pool is handing out two concurrent connections. + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !second_handle.is_finished(), + "second reader must be parked while first holds the only connection" + ); + + // Release the first reader. + { + let mut r = gate.released.lock().unwrap(); + *r = true; + gate.cv_released.notify_one(); + } + + // Both readers must complete promptly. + let second = timeout(Duration::from_secs(2), second_handle) + .await + .expect("second reader should eventually complete within 2s") + .expect("second reader join") + .expect("second reader's query should succeed"); + assert_eq!(second, 2); + held.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn reader_error_propagates_and_connection_returns_to_pool() { + let dir = tempfile::tempdir().unwrap(); + let path = prepared_db(&dir); + let pool = ReaderPool::open(&path, 2).expect("pool"); + + // First call returns an error from the closure. + let err_result: Result = pool + .with_reader(|conn| { + let _: i64 = conn.query_row("SELECT 1", [], |row| row.get(0))?; + // Deliberate invalid SQL to force an error in the closure. + conn.query_row("SELECT * FROM non_existent_table", [], |row| row.get(0)) + .map_err(clarion_storage::StorageError::from) + }) + .await; + + assert!(err_result.is_err(), "expected closure error to propagate"); + assert!(matches!( + err_result.unwrap_err(), + clarion_storage::StorageError::Sqlite(_) + )); + + // Second call on the same pool must succeed — proves the connection + // from the first call was returned to the pool cleanly. + let ok: i64 = pool + .with_reader(|conn| { + let n: i64 = conn.query_row("SELECT 42", [], |row| row.get(0))?; + Ok(n) + }) + .await + .expect("subsequent reader after an error should succeed"); + assert_eq!(ok, 42); +} + +#[tokio::test] +async fn reader_panic_is_caught_as_pool_interact_and_pool_remains_usable() { + let dir = tempfile::tempdir().unwrap(); + let path = prepared_db(&dir); + let pool = ReaderPool::open(&path, 2).expect("pool"); + + // Closure that panics inside the interact() block. + let panic_result: Result = pool + .with_reader(|_conn| { + panic!("deliberate test panic inside reader closure"); + }) + .await; + + assert!(panic_result.is_err(), "expected panic to surface as error"); + assert!(matches!( + panic_result.unwrap_err(), + clarion_storage::StorageError::PoolInteract(_) + )); + + // Pool remains usable — deadpool recycles the poisoned connection or + // discards it and creates a fresh one. Subsequent calls must succeed. + let ok: i64 = pool + .with_reader(|conn| { + let n: i64 = conn.query_row("SELECT 99", [], |row| row.get(0))?; + Ok(n) + }) + .await + .expect("subsequent reader after a panic should succeed"); + assert_eq!(ok, 99); +} diff --git a/crates/clarion-storage/tests/schema_apply.rs b/crates/clarion-storage/tests/schema_apply.rs new file mode 100644 index 00000000..012e779e --- /dev/null +++ b/crates/clarion-storage/tests/schema_apply.rs @@ -0,0 +1,232 @@ +//! Schema-apply integration tests. +//! +//! Verifies that migration 0001 produces every table, index, trigger, +//! generated column, and view from detailed-design.md §3, and that +//! applying migrations a second time is a no-op. + +use rusqlite::{Connection, params}; + +use clarion_storage::{pragma, schema}; + +fn open_fresh(tempdir: &tempfile::TempDir) -> Connection { + let path = tempdir.path().join("clarion.db"); + let mut conn = Connection::open(&path).expect("open"); + pragma::apply_write_pragmas(&conn).expect("pragmas"); + schema::apply_migrations(&mut conn).expect("apply migrations"); + conn +} + +fn table_names(conn: &Connection) -> Vec { + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .map(std::result::Result::unwrap) + .collect() +} + +fn trigger_names(conn: &Connection) -> Vec { + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .map(std::result::Result::unwrap) + .collect() +} + +fn view_names(conn: &Connection) -> Vec { + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type='view' ORDER BY name") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .map(std::result::Result::unwrap) + .collect() +} + +fn index_names(conn: &Connection) -> Vec { + let mut stmt = conn + .prepare( + "SELECT name FROM sqlite_master \ + WHERE type='index' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .map(std::result::Result::unwrap) + .collect() +} + +#[test] +fn migration_0001_creates_every_expected_table() { + let tempdir = tempfile::tempdir().unwrap(); + let conn = open_fresh(&tempdir); + let tables = table_names(&conn); + for expected in &[ + "edges", + "entities", + "entity_tags", + "findings", + "runs", + "schema_migrations", + "summary_cache", + ] { + assert!( + tables.iter().any(|t| t == expected), + "missing table {expected} in {tables:?}" + ); + } +} + +#[test] +fn migration_0001_creates_entity_fts_virtual_table() { + let tempdir = tempfile::tempdir().unwrap(); + let conn = open_fresh(&tempdir); + let sql: String = conn + .query_row( + "SELECT sql FROM sqlite_master WHERE name='entity_fts'", + [], + |row| row.get(0), + ) + .unwrap(); + assert!(sql.contains("CREATE VIRTUAL TABLE"), "sql was: {sql}"); + conn.execute_batch("SELECT entity_id, name FROM entity_fts LIMIT 0") + .expect("entity_fts queryable"); +} + +#[test] +fn migration_0001_creates_all_three_fts_triggers() { + let tempdir = tempfile::tempdir().unwrap(); + let conn = open_fresh(&tempdir); + let triggers = trigger_names(&conn); + for expected in &["entities_ad", "entities_ai", "entities_au"] { + assert!( + triggers.iter().any(|t| t == expected), + "missing trigger {expected} in {triggers:?}" + ); + } +} + +#[test] +fn migration_0001_creates_guidance_sheets_view() { + let tempdir = tempfile::tempdir().unwrap(); + let conn = open_fresh(&tempdir); + let views = view_names(&conn); + assert!( + views.iter().any(|v| v == "guidance_sheets"), + "views: {views:?}" + ); + conn.execute_batch("SELECT id, name, priority FROM guidance_sheets LIMIT 0") + .expect("guidance_sheets queryable"); +} + +#[test] +fn migration_0001_creates_partial_indexes() { + let tempdir = tempfile::tempdir().unwrap(); + let conn = open_fresh(&tempdir); + let indexes = index_names(&conn); + for expected in &["ix_entities_churn", "ix_entities_priority"] { + assert!( + indexes.iter().any(|i| i == expected), + "missing index {expected} in {indexes:?}" + ); + } +} + +#[test] +fn entity_generated_columns_extract_from_properties_json() { + let tempdir = tempfile::tempdir().unwrap(); + let conn = open_fresh(&tempdir); + let props = r#"{"priority": 2, "git_churn_count": 42}"#; + conn.execute( + "INSERT INTO entities (id, plugin_id, kind, name, short_name, properties, \ + created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, \ + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + params![ + "python:function:demo.f", + "python", + "function", + "demo.f", + "f", + props + ], + ) + .unwrap(); + // priority is a TEXT-affinity generated column; json_extract yields the + // JSON-native integer but SQLite coerces it to text on storage. + let (priority, churn): (Option, Option) = conn + .query_row( + "SELECT priority, git_churn_count FROM entities WHERE id = ?1", + params!["python:function:demo.f"], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(priority.as_deref(), Some("2")); + assert_eq!(churn, Some(42)); +} + +#[test] +fn fts_trigger_populates_entity_fts_on_insert() { + let tempdir = tempfile::tempdir().unwrap(); + let conn = open_fresh(&tempdir); + let summary_json = r#"{"briefing": {"purpose": "refresh session tokens"}}"#; + conn.execute( + "INSERT INTO entities (id, plugin_id, kind, name, short_name, properties, summary, \ + created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, '{}', ?6, \ + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + params![ + "python:function:auth.refresh", + "python", + "function", + "auth.refresh", + "refresh", + summary_json, + ], + ) + .unwrap(); + + // MATCH against the FTS5 virtual table; the entities_ai trigger should have + // populated the summary_text field from summary.briefing.purpose. + let matched_id: String = conn + .query_row( + "SELECT entity_id FROM entity_fts WHERE entity_fts MATCH 'refresh'", + [], + |row| row.get(0), + ) + .expect("entity_fts row should exist after INSERT trigger fires"); + assert_eq!(matched_id, "python:function:auth.refresh"); +} + +#[test] +fn migrations_are_idempotent() { + let tempdir = tempfile::tempdir().unwrap(); + let mut conn = open_fresh(&tempdir); + schema::apply_migrations(&mut conn).expect("second apply should be a no-op"); + assert_eq!(schema::applied_count(&conn).unwrap(), 1); + let tables_after = table_names(&conn); + assert!(tables_after.contains(&"entities".to_owned())); +} + +#[test] +fn schema_migrations_records_one_row() { + let tempdir = tempfile::tempdir().unwrap(); + let conn = open_fresh(&tempdir); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 1); + let name: String = conn + .query_row( + "SELECT name FROM schema_migrations WHERE version = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(name, "0001_initial_schema"); +} diff --git a/crates/clarion-storage/tests/writer_actor.rs b/crates/clarion-storage/tests/writer_actor.rs new file mode 100644 index 00000000..a481a4e4 --- /dev/null +++ b/crates/clarion-storage/tests/writer_actor.rs @@ -0,0 +1,359 @@ +//! Writer-actor integration tests. +//! +//! Covers: round-trip insert, per-N-batch commit cadence, `FailRun` rollback. + +use std::sync::atomic::Ordering; + +use rusqlite::Connection; +use tokio::sync::oneshot; + +use clarion_storage::{ + ReaderPool, Writer, + commands::{EntityRecord, RunStatus, WriterCmd}, + pragma, schema, +}; + +fn prepared_db(dir: &tempfile::TempDir) -> std::path::PathBuf { + let path = dir.path().join("clarion.db"); + let mut conn = Connection::open(&path).unwrap(); + pragma::apply_write_pragmas(&conn).unwrap(); + schema::apply_migrations(&mut conn).unwrap(); + path +} + +fn now_iso() -> String { + "2026-04-18T00:00:00.000Z".to_owned() +} + +fn make_entity(id: &str) -> EntityRecord { + EntityRecord { + id: id.to_owned(), + plugin_id: "python".to_owned(), + kind: "function".to_owned(), + name: "demo.hello".to_owned(), + short_name: "hello".to_owned(), + parent_id: None, + source_file_id: None, + source_byte_start: None, + source_byte_end: None, + source_line_start: None, + source_line_end: None, + properties_json: "{}".to_owned(), + content_hash: None, + summary_json: None, + wardline_json: None, + first_seen_commit: None, + last_seen_commit: None, + created_at: now_iso(), + updated_at: now_iso(), + } +} + +async fn send( + tx: &tokio::sync::mpsc::Sender, + build: impl FnOnce(oneshot::Sender>) -> WriterCmd, +) -> Result { + let (ack_tx, ack_rx) = oneshot::channel(); + tx.send(build(ack_tx)).await.unwrap(); + ack_rx.await.unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn round_trip_insert_persists_entity() { + let dir = tempfile::tempdir().unwrap(); + let path = prepared_db(&dir); + let (writer, handle) = Writer::spawn(path.clone(), 50, 256).unwrap(); + let tx = writer.sender(); + + send::<()>(&tx, |ack| WriterCmd::BeginRun { + run_id: "run-1".into(), + config_json: "{}".into(), + started_at: now_iso(), + ack, + }) + .await + .unwrap(); + + send::<()>(&tx, |ack| WriterCmd::InsertEntity { + entity: Box::new(make_entity("python:function:demo.hello")), + ack, + }) + .await + .unwrap(); + + send::<()>(&tx, |ack| WriterCmd::CommitRun { + run_id: "run-1".into(), + status: RunStatus::Completed, + completed_at: now_iso(), + stats_json: "{}".into(), + ack, + }) + .await + .unwrap(); + + drop(tx); + drop(writer); + handle.await.unwrap().unwrap(); + + let pool = ReaderPool::open(&path, 2).unwrap(); + let count: i64 = pool + .with_reader(|conn| { + let n: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0))?; + Ok(n) + }) + .await + .unwrap(); + assert_eq!(count, 1); + + let kind: String = pool + .with_reader(|conn| { + let k: String = conn.query_row( + "SELECT kind FROM entities WHERE id = ?1", + rusqlite::params!["python:function:demo.hello"], + |row| row.get(0), + )?; + Ok(k) + }) + .await + .unwrap(); + assert_eq!(kind, "function"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn batch_size_fifty_commits_every_fifty_inserts() { + let dir = tempfile::tempdir().unwrap(); + let path = prepared_db(&dir); + let (writer, handle) = Writer::spawn(path.clone(), 50, 256).unwrap(); + let tx = writer.sender(); + + send::<()>(&tx, |ack| WriterCmd::BeginRun { + run_id: "run-1".into(), + config_json: "{}".into(), + started_at: now_iso(), + ack, + }) + .await + .unwrap(); + + for i in 0..150 { + let id = format!("python:function:demo.f{i:03}"); + send::<()>(&tx, |ack| WriterCmd::InsertEntity { + entity: Box::new(make_entity(&id)), + ack, + }) + .await + .unwrap(); + } + + assert_eq!(writer.commits_observed.load(Ordering::Relaxed), 3); + + send::<()>(&tx, |ack| WriterCmd::CommitRun { + run_id: "run-1".into(), + status: RunStatus::Completed, + completed_at: now_iso(), + stats_json: "{}".into(), + ack, + }) + .await + .unwrap(); + + assert_eq!(writer.commits_observed.load(Ordering::Relaxed), 4); + + drop(tx); + drop(writer); + handle.await.unwrap().unwrap(); + + let pool = ReaderPool::open(&path, 2).unwrap(); + let count: i64 = pool + .with_reader(|conn| { + let n: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0))?; + Ok(n) + }) + .await + .unwrap(); + assert_eq!(count, 150); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fail_run_rolls_back_pending_inserts() { + let dir = tempfile::tempdir().unwrap(); + let path = prepared_db(&dir); + let (writer, handle) = Writer::spawn(path.clone(), 50, 256).unwrap(); + let tx = writer.sender(); + + send::<()>(&tx, |ack| WriterCmd::BeginRun { + run_id: "run-fail".into(), + config_json: "{}".into(), + started_at: now_iso(), + ack, + }) + .await + .unwrap(); + + for i in 0..10 { + let id = format!("python:function:demo.g{i:03}"); + send::<()>(&tx, |ack| WriterCmd::InsertEntity { + entity: Box::new(make_entity(&id)), + ack, + }) + .await + .unwrap(); + } + + send::<()>(&tx, |ack| WriterCmd::FailRun { + run_id: "run-fail".into(), + reason: "deliberate test failure".into(), + completed_at: now_iso(), + ack, + }) + .await + .unwrap(); + + drop(tx); + drop(writer); + handle.await.unwrap().unwrap(); + + let pool = ReaderPool::open(&path, 2).unwrap(); + let entity_count: i64 = pool + .with_reader(|conn| { + let n: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0))?; + Ok(n) + }) + .await + .unwrap(); + assert_eq!(entity_count, 0, "FailRun did not roll back inserts"); + + let status: String = pool + .with_reader(|conn| { + let s: String = + conn.query_row("SELECT status FROM runs WHERE id = 'run-fail'", [], |row| { + row.get(0) + })?; + Ok(s) + }) + .await + .unwrap(); + assert_eq!(status, "failed"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn insert_entity_without_begin_run_is_protocol_violation() { + let dir = tempfile::tempdir().unwrap(); + let path = prepared_db(&dir); + let (writer, handle) = Writer::spawn(path.clone(), 50, 256).unwrap(); + let tx = writer.sender(); + + let result = send::<()>(&tx, |ack| WriterCmd::InsertEntity { + entity: Box::new(make_entity("python:function:demo.early")), + ack, + }) + .await; + + let err = result.expect_err("InsertEntity without BeginRun should fail"); + assert!( + matches!(err, clarion_storage::StorageError::WriterProtocol(_)), + "expected WriterProtocol, got {err:?}" + ); + + drop(tx); + drop(writer); + handle.await.unwrap().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn double_begin_run_is_protocol_violation() { + let dir = tempfile::tempdir().unwrap(); + let path = prepared_db(&dir); + let (writer, handle) = Writer::spawn(path.clone(), 50, 256).unwrap(); + let tx = writer.sender(); + + send::<()>(&tx, |ack| WriterCmd::BeginRun { + run_id: "run-a".into(), + config_json: "{}".into(), + started_at: now_iso(), + ack, + }) + .await + .unwrap(); + + let result = send::<()>(&tx, |ack| WriterCmd::BeginRun { + run_id: "run-b".into(), + config_json: "{}".into(), + started_at: now_iso(), + ack, + }) + .await; + + let err = result.expect_err("second BeginRun should fail"); + assert!( + matches!(err, clarion_storage::StorageError::WriterProtocol(_)), + "expected WriterProtocol, got {err:?}" + ); + + drop(tx); + drop(writer); + handle.await.unwrap().unwrap(); +} + +/// Regression for review finding #8: if the channel closes while a run is +/// still open (e.g. the Writer is dropped before CommitRun/FailRun is sent), +/// the actor must update the `runs` row to `status='failed'` rather than +/// leaving it stuck at `'running'`. Without this, every crashed analyze +/// accumulates an orphaned row. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn channel_close_with_open_run_self_heals_to_failed() { + let dir = tempfile::tempdir().unwrap(); + let path = prepared_db(&dir); + let (writer, handle) = Writer::spawn(path.clone(), 50, 256).unwrap(); + let tx = writer.sender(); + + send::<()>(&tx, |ack| WriterCmd::BeginRun { + run_id: "run-abandoned".into(), + config_json: "{}".into(), + started_at: now_iso(), + ack, + }) + .await + .unwrap(); + + send::<()>(&tx, |ack| WriterCmd::InsertEntity { + entity: Box::new(make_entity("python:function:demo.hello")), + ack, + }) + .await + .unwrap(); + + // Caller disappears mid-run — no CommitRun / FailRun sent. + drop(tx); + drop(writer); + handle.await.unwrap().unwrap(); + + // The run row must have been self-healed to 'failed'. The pending insert + // is rolled back. + let pool = ReaderPool::open(&path, 1).expect("pool"); + let (observed_status, observed_reason, entity_count): (String, String, i64) = pool + .with_reader(|conn| { + let (s, st): (String, String) = conn.query_row( + "SELECT status, stats FROM runs WHERE id = 'run-abandoned'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let n: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0))?; + Ok((s, st, n)) + }) + .await + .expect("reader query"); + + assert_eq!( + observed_status, "failed", + "self-heal must mark abandoned run as failed" + ); + assert!( + observed_reason.contains("writer channel closed unexpectedly"), + "failure_reason must cite channel close; got stats = {observed_reason}" + ); + assert_eq!( + entity_count, 0, + "pending insert must be rolled back when channel closes" + ); +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..2802a164 --- /dev/null +++ b/deny.toml @@ -0,0 +1,32 @@ +# deny.toml — cargo-deny v2 schema. Anything not in `allow` is denied. + +[advisories] +version = 2 +yanked = "deny" +ignore = [] + +[licenses] +version = 2 +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "ISC", + "Unicode-3.0", + "Unicode-DFS-2016", + "Zlib", +] +confidence-threshold = 0.8 + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/docs/clarion/adr/ADR-005-clarion-dir-tracking.md b/docs/clarion/adr/ADR-005-clarion-dir-tracking.md new file mode 100644 index 00000000..5750a2b5 --- /dev/null +++ b/docs/clarion/adr/ADR-005-clarion-dir-tracking.md @@ -0,0 +1,168 @@ +# ADR-005: `.clarion/` Directory Git-Tracking Policy + +**Status**: Accepted +**Date**: 2026-04-18 +**Deciders**: qacona@gmail.com +**Context**: `clarion install` must write a `.gitignore` inside `.clarion/` that +separates committed analysis state from volatile per-run artefacts. Sprint 1 WP1 +Task 5 is the authoring trigger; before this ADR, the rules were only proposed +in `docs/implementation/sprint-1/wp1-scaffold.md §UQ-WP1-04`. + +## Summary + +`.clarion/clarion.db` and `.clarion/config.json` are committed. WAL sidecars, +the shadow-DB intermediate, `tmp/`, `logs/`, and per-run raw LLM request/response +logs (`runs/*/log.jsonl`) are `.gitignore`d. `clarion.yaml` lives at the project +root and is tracked under the user's existing repo-root `.gitignore`, not under +`.clarion/.gitignore` (it's a user-edited config, not analysis state). + +## Context + +`.clarion/` mixes artefact kinds that want different tracking posture: + +- **Shared analysis state** (entities, edges, briefings, guidance) — diff-friendly + via `clarion db export --textual`; solo-developer and small-team cases benefit + from having briefings versioned alongside the code they describe + (`detailed-design.md §3 File layout`). +- **Runtime write-ahead files** (`*-wal`, `*-shm`) — SQLite bookkeeping that is + process-local and meaningless on a different machine. +- **Shadow DB** (`clarion.db.new`, `*.shadow.db`) — ADR-011's `--shadow-db` + intermediate; deleted on successful atomic rename, would leak as junk + otherwise. +- **Per-run LLM bodies** (`runs//log.jsonl`) — raw request/response + bodies for audit. May contain source excerpts fine to ship to Anthropic + but not appropriate to commit to a public repo. +- **Scratch** (`tmp/`, `logs/`) — volatile by definition. + +Without this ADR, `clarion install` has no normative place to look up the rules, +and every developer's install produces their own variant `.gitignore` by accident. + +## Decision + +`clarion install` writes `.clarion/.gitignore` with the following contents +(verbatim — the literal file lives at +`crates/clarion-cli/src/install.rs` and ships as the v0.1 baseline): + +``` +*-wal +*-shm +*.db-wal +*.db-shm +*.shadow.db +*.db.new +tmp/ +logs/ +runs/*/log.jsonl +``` + +### Tracked + +- `.clarion/clarion.db` — the main analysis store. SQLite diffs poorly; the + `clarion db export --textual` + `clarion db merge-helper` pattern (detailed + design §3 File layout) handles the team case. +- `.clarion/config.json` — small, human-readable internal state (schema + version, last run IDs). +- `.clarion/.gitignore` itself — this file. +- `.clarion/runs//config.yaml` — the snapshot of `clarion.yaml` at run + time. Material for provenance replay. +- `.clarion/runs//stats.json` — run statistics. +- `.clarion/runs//partial.json` — present only for partial runs; + material for `--resume`. + +### Excluded + +- All SQLite WAL + SHM sidecars. +- All shadow-DB intermediates. +- `tmp/` and `logs/` (volatile scratch). +- `runs/*/log.jsonl` (raw LLM bodies — audit-local, not commit-appropriate). + +### Out of scope for `.clarion/.gitignore` + +- `clarion.yaml` (the user-edited config) lives at the *project root*, not + inside `.clarion/`. Its tracking is governed by the project's own repo-root + `.gitignore`, which is the user's concern. Default posture: tracked. + +### Opt-out for users who don't want the DB committed + +`clarion.yaml:storage.commit_db: false` (post-Sprint-1 knob; WP6 authors the +full `clarion.yaml` schema). When false, Clarion writes an additional +`.clarion/.gitignore` line excluding `clarion.db`, and emits +`clarion db sync push/pull` commands. Not implemented in Sprint 1; the knob +is documented here so the future change has a home. + +## Alternatives Considered + +### Alternative 1: commit everything + +**Pros**: no ignore list to maintain. + +**Cons**: WAL sidecars break repos (they're process-local binary files); raw +LLM bodies may contain material the user does not want public. + +**Why rejected**: blast radius of a single `git push` with `runs/*/log.jsonl` +committed is unbounded. + +### Alternative 2: commit nothing + +**Pros**: simplest — `.clarion/` becomes entirely machine-local. + +**Cons**: loses the "shared analysis state" benefit — briefings and guidance +are derived outputs that are expensive to rebuild. Small teams especially +benefit from having them versioned alongside the code. + +**Why rejected**: the "enterprise rigor at lack of scale" posture favours +committing analytic state for small-team workflows. Users who want machine-local +analysis only opt out via `storage.commit_db: false`. + +### Alternative 3: commit the DB but use git-lfs by default + +**Pros**: keeps small-git-diff UX (LFS handles the binary file). + +**Cons**: requires git-lfs installed on every developer machine; makes `clarion +install` a multi-tool setup; adds failure modes (lfs server availability, large +file policy). v0.1 target workflows are solo/small-team where the straight-commit +path works; LFS is a v0.2+ knob. + +**Why rejected**: premature infrastructure for the v0.1 audience. + +## Consequences + +### Positive + +- Every `clarion install` produces the same `.gitignore`. Ends per-developer + drift on "what should be committed." +- WAL sidecars cannot accidentally land in a commit. +- Raw LLM bodies stay local to the developer that ran the analysis. +- `--shadow-db` intermediates (ADR-011) are excluded by the same list, so + users adopting that mode don't discover an ignore gap post-hoc. + +### Negative + +- Committed SQLite DBs diff poorly by default. Mitigation: the + `clarion db export --textual` / merge-helper path (detailed-design §3) is + the documented escape hatch. +- Adding a new excluded pattern requires either a Clarion release or a + user-side `.clarion/.gitignore` edit. The post-v0.1 plan is to keep this + file tool-owned; users adding their own ignores put them in the repo-root + `.gitignore`, not here. + +### Neutral + +- `storage.commit_db: false` is a defined but unimplemented opt-out. Sprint 1 + ships with the commit-the-DB default only. + +## Related Decisions + +- [ADR-011](./ADR-011-writer-actor-concurrency.md) — names the shadow-DB + intermediate; this ADR excludes it from git. +- [ADR-014](./ADR-014-filigree-registry-backend.md) — cross-tool references + rely on `clarion.db` being available to readers (Filigree, Wardline); the + commit-by-default posture keeps those references resolvable across machines. + +## References + +- [detailed-design.md §3 File layout](../v0.1/detailed-design.md#file-layout) — + the prose version of this decision, now superseded by this ADR as the + normative source. +- [wp1-scaffold.md UQ-WP1-04](../../implementation/sprint-1/wp1-scaffold.md) — + the sprint-local resolution this ADR formalises. diff --git a/docs/clarion/adr/ADR-023-tooling-baseline.md b/docs/clarion/adr/ADR-023-tooling-baseline.md new file mode 100644 index 00000000..3793b95f --- /dev/null +++ b/docs/clarion/adr/ADR-023-tooling-baseline.md @@ -0,0 +1,323 @@ +# ADR-023: Rust + Python Tooling Baseline at the Zero-Code Frontier + +**Status**: Accepted +**Date**: 2026-04-18 +**Deciders**: qacona@gmail.com +**Context**: first implementation commits (Sprint 1 WP1) are about to land in a +documentation-only repository. The workspace's lint, format, edition, test-runner, +supply-chain, CI, and type-check posture is about to be locked into the first +commit graph — either deliberately or by default. Setting these surfaces now +costs close to zero; retrofitting after any Clarion/Wardline-scale code has +been written is expensive. + +## Summary + +The Clarion workspace adopts a strict tooling baseline from its first code +commit, before any implementation lands: + +- **Rust**: edition **2024**, workspace-level `[lints]` block with + `clippy::pedantic = "warn"` + `unsafe_code = "forbid"`, `rustfmt.toml` + pinned, `clippy.toml` with relaxed pedantic thresholds, `cargo-nextest` as + the test runner, `cargo-deny` for supply-chain hygiene, GitHub Actions CI + running fmt-check, pedantic clippy, nextest, deny, and doc build. +- **Python** (plugin side): `ruff` for lint + format (strict config), + `mypy --strict` from the first commit, `pytest` for tests, `pre-commit` + wiring ruff + mypy into every `git commit`. +- **ADR precedent**: this baseline is the floor; later WPs may raise it + (coverage gating, cargo-audit, nightly-only rustfmt options) but may not + lower it without a superseding ADR. + +Sprint 1 Work Package 1 (scaffold + storage) is the implementation trigger. +WP1 Task 1 ships all the configuration files named below. WP3 Task 1 ships +the Python equivalents. + +## Context + +The Clarion repository sat at zero lines of code on 2026-04-18. Sprint 1's +WP1 was about to land a three-crate Cargo workspace, a SQLite migration, a +writer-actor, and a CLI skeleton. The original WP1 plan (UQ-WP1-09 resolution +at `docs/implementation/sprint-1/wp1-scaffold.md`) committed to Rust edition +2021 + the default `cargo test` runner + no formal lint gate beyond +`-D warnings`. + +That resolution was written under "fine to document and move on" framing — +the canonical tell for decisions that survive into production as unexamined +baselines. Three observations forced the re-examination: + +1. **Edition 2024 has been stable since February 2025.** No v0.1 dependency + in the planned graph (`rusqlite`, `deadpool-sqlite`, `tokio`, `clap`, + `thiserror`, `tracing`) constrains to 2021. The 2021 choice was + inherited, not motivated. +2. **Workspace-level `[lints]` with pedantic enabled is near-free at + greenfield and expensive to retrofit.** Each crate that exists before + pedantic is introduced must be audited and silenced or fixed; starting + pedantic-clean means every new contribution passes against the strict + floor from day one. The scope-commitment memo already commits Clarion to + "enterprise rigor at lack of scale" (`plans/v0.1-scope-commitments.md`). + Pedantic is the cheapest expression of that commitment that exists. +3. **Sprint 1 has four `cargo test` call sites today**; Sprint 2+ will have + dozens. Moving to `cargo-nextest` after the first sprint forces a + workspace-wide find-and-replace across CI, docs, and runbooks. Moving + now is a one-line `cargo.toml` change plus a single `install-action` + step in CI. + +The Python side runs the same argument. UQ-WP3-10 in `wp3-python-plugin.md` +deferred mypy "until the plugin grows enough to benefit." That's the same +"fine to document and move on" frame: every Python module written without +mypy-strict accumulates as a retrofit surface. Adopting mypy-strict at +Task 1 means every extractor, probe, and server-loop module is written +with full type coverage from the first keystroke. + +## Decision + +### Rust (workspace-wide) + +**Edition**: 2024. + +**Toolchain pin** (`rust-toolchain.toml` at repo root): + +```toml +[toolchain] +channel = "stable" +components = ["clippy", "rustfmt", "llvm-tools-preview"] +profile = "minimal" +``` + +`llvm-tools-preview` is included because it costs nothing at install time +and makes `cargo install cargo-llvm-cov` work first try if a later WP wants +local coverage — the retrofit path cost would be higher than carrying the +component from the start. + +**Workspace `[lints]` block** (in root `Cargo.toml`): + +```toml +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +pedantic = "warn" +# Pragmatic allows — revisit per WP if the floor is too loud: +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +``` + +Every member crate declares `lints.workspace = true` so a later-added crate +cannot drift off the baseline. + +**`rustfmt.toml`**: + +```toml +edition = "2024" +max_width = 100 +newline_style = "Unix" +use_field_init_shorthand = true +use_try_shorthand = true +``` + +**`clippy.toml`**: + +```toml +cognitive-complexity-threshold = 15 +too-many-arguments-threshold = 8 +too-many-lines-threshold = 120 +``` + +**Test runner**: `cargo nextest run` (a dev-dep install managed via +`taiki-e/install-action@cargo-nextest` in CI). Exit criteria and demo +scripts use `cargo nextest run`, not `cargo test`. + +**Supply-chain**: `cargo-deny` with `deny.toml` (v2 schema) checking +advisories (`yanked = "deny"`), license allowlist (MIT, Apache-2.0, +Apache-2.0 WITH LLVM-exception, BSD-2-Clause, BSD-3-Clause, ISC, +Unicode-3.0, Unicode-DFS-2016), multi-version `"warn"`, wildcards `"deny"`, +unknown-registry/unknown-git `"deny"`. + +**CI** (`.github/workflows/ci.yml` on push to main + every PR) runs: + +1. `cargo fmt --all -- --check` +2. `cargo clippy --all-targets --all-features -- -D warnings` +3. `cargo nextest run --all-features` +4. `cargo doc --no-deps --all-features` +5. `cargo deny check` + +Any PR merging to main must pass all five gates. + +### Python (plugin side) + +**Tooling stack**: + +- **`ruff`** — lint + format. Strict config at `plugins/python/ruff.toml` or + `[tool.ruff]` in `pyproject.toml`. Select rules: `ALL` minus pragmatic + excludes (`D` docstring lints relaxed; `COM812` / `ISC001` that conflict + with format; explicit per-file-ignores for tests and fixtures). +- **`mypy --strict`** from day 1. Config at `plugins/python/mypy.ini` or + `[tool.mypy]` block; `strict = true` plus explicit module entries for + third-party deps without stubs. +- **`pytest`** + `pytest-cov`. Coverage reported but not gated in Sprint 1; + a WP6-era coverage floor may be added later as a raise-the-ceiling change. +- **`pre-commit`** with hooks for `ruff check`, `ruff format`, `mypy`. + Installed via `pre-commit install` after `pip install -e .[dev]`. + +**CI extension** (same workflow, separate job): install `uv`, install the +plugin editable with dev extras, run `ruff check`, `ruff format --check`, +`mypy --strict`, `pytest`. + +### ADR precedent + +- This baseline is a **floor**. Future WPs may tighten (e.g., add a + coverage-% gate, promote `missing_errors_doc` to warn) but must not + loosen. Loosening requires a superseding ADR with a named justification. +- Tool version pins live in CI (`taiki-e/install-action` resolves latest by + default; pin to exact version if a WP hits a regression). Workspace + `Cargo.toml` never downgrades edition; `rust-toolchain.toml` never + regresses its channel. + +## Alternatives Considered + +### Alternative 1: retain the original WP1 baseline (edition 2021, `cargo test`, no CI) + +**Pros**: matches the pre-authored WP1 Task ledger verbatim; zero doc churn; +faster to start writing code in the current session. + +**Cons**: bakes in three retrofit surfaces (edition migration, pedantic +introduction, CI wiring) that compound with every commit that lands before +they're addressed. The WP1 author (same author as this ADR, several hours +earlier) flagged UQ-WP1-09 as "fine to document and move on" — the canonical +signal for decisions that deserve re-examination precisely because nobody +has looked at them twice. + +**Why rejected**: the cost of changing direction at commit zero is the cost +of re-running this doc edit plus Task 1 reprep. The cost of changing +direction at commit 500 is auditing every line of code against a new lint +floor. The asymmetry is large enough that "the plan already says so" is not +a sufficient reason to hold. + +### Alternative 2: adopt the baseline but defer Python's mypy-strict + +**Pros**: WP3 starts faster; Python's first iterations are less type-churn. + +**Cons**: mypy-strict retrofit is identical in shape to pedantic retrofit — +every module written without it is a module to audit. The plugin's first +module (Task 1 Python package skeleton) is ~20 lines; writing it against +strict is a trivial fraction of the authoring time. + +**Why rejected**: the same cost-asymmetry argument that rejects Alternative +1 also rejects this partial version. + +### Alternative 3: adopt everything plus coverage % gating + cargo-audit + nightly rustfmt + +**Pros**: maximum strictness posture. + +**Cons**: coverage % needs real code density before a meaningful floor +emerges (Sprint 1 is too small to set one without hand-tuning). `cargo-deny` +advisories database subsumes `cargo-audit` — running both is duplicate +work. Nightly rustfmt options (`imports_granularity`, `group_imports`) +require every contributor to install a nightly toolchain or CI to pin one. + +**Why rejected**: scope-creep past "floor at the zero-code frontier" into +"everything a mature project eventually has." The baseline names exactly +the surfaces that are cheap now and expensive later; the three above are +either scale-dependent (coverage) or redundant (cargo-audit) or a +cross-team burden (nightly rustfmt). + +### Alternative 4: defer CI to "when the repo becomes shared" + +**Pros**: saves the ~20-line GitHub Actions file and one-time CI-wiring +debugging. + +**Cons**: CI for a solo-maintainer repo is not about collaboration — it's +about discipline. Running the five-gate sequence on every PR ensures no +local-only `cargo check` can ever pass into main. The cost of writing a +fresh CI workflow from memory at Sprint 5 is higher than the cost of +starting one at Sprint 1 and letting it accumulate minor additions. + +**Why rejected**: CI's value is insurance against the class of errors that +manifest only when something runs on a clean machine. That class exists +from commit one. The workflow is small enough that "defer until needed" +generates less value than the discipline floor it provides. + +## Consequences + +### Positive + +- Every future Clarion commit passes pedantic clippy, rustfmt, cargo-deny, + and — for Python — ruff + mypy-strict. The debt load is structurally + bounded at zero. +- New contributors (or new-me after a context switch) inherit the same + floor without needing to re-litigate it. `clippy.toml` + + `rustfmt.toml` + `deny.toml` + `mypy.ini` are self-documenting. +- Edition 2024 gives first-class access to 2024-era features (improved + `let-else` diagnostics, RPITIT stabilisations, etc.) without a migration + gate later. +- `cargo nextest` halves test-suite wall-clock time on workspace builds + versus `cargo test`; for WP1's already-growing test count (12 integration + tests in `clarion-storage` alone), the compounded savings are non-trivial. +- ADR-023's existence as a discoverable decision record means "why is this + pedantic?" answers itself without a git-archaeology trip. + +### Negative + +- Every lint expansion or strictness increase (`clippy::pedantic` ships + with ~50 lints; a Rust-stable release may add more) can, in principle, + break a future `cargo clippy` run after a toolchain update. Mitigation: + `rust-toolchain.toml` pins the channel, so upgrades are explicit events; + CI catches breakage at PR time, not at master. +- `mypy --strict` imposes real authoring cost on Python code — every `dict` + needs its key/value types spelled out, every `None` return needs its + annotation, every callable argument needs its signature. For the Sprint + 1 Python plugin scope (one extractor, one probe, one JSON-RPC loop, + ~300 LOC) this is acceptable. +- Pedantic's pragmatic allows (`module_name_repetitions`, + `must_use_candidate`, `missing_errors_doc`) are judgment calls. A later + WP that finds one of them burying a real bug should author a + superseding-lint ADR to tighten the allow-list. +- GitHub Actions CI introduces a dependency on GitHub's CI infrastructure + for the merge gate. An extended GitHub outage can't be worked around by + pushing straight to main without the five gates passing. Mitigation: + local pre-commit (Python) + `./scripts/ci-local.sh` (Rust, not yet + written — WP1 or Sprint 2 can add) lets solo contributors run the same + sequence offline. + +### Neutral + +- `cargo-deny`'s license allowlist is not exhaustive; new dependencies + with unlisted licenses will fail `cargo deny check` and require an + explicit allowlist expansion (with a commit message noting the license + and reason). This is the intended shape — surfacing license decisions, + not burying them. +- `rust-toolchain.toml` pinning to `channel = "stable"` means local + `cargo` installs float with whatever stable rustc is current when a + contributor runs `rustup update`. If Sprint 5 wants reproducible + toolchain versions, pin to a specific `1.XY.Z` string in a single + commit. + +## Related Decisions + +- [ADR-001](./ADR-001-rust-for-core.md) — picks Rust as the core + implementation language. ADR-023 is ADR-001's operational complement: + given Rust, here is how we write it. +- [ADR-011](./ADR-011-writer-actor-concurrency.md) — locks the + `rusqlite` + `deadpool-sqlite` + `tokio` crate stack. ADR-023 pins the + edition those crates compile against and the lint floor every call site + to them must pass. +- [ADR-022](./ADR-022-core-plugin-ontology.md) — sets the manifest-acceptance + contract. Plugins authored in Python (WP3) or later in other languages + can adopt tooling baselines appropriate to their ecosystem; the Rust + host's baseline is defined here. +- Scope-commitment memo [`../v0.1/plans/v0.1-scope-commitments.md`](../v0.1/plans/v0.1-scope-commitments.md) + — "enterprise rigor at lack of scale" is the phrase this ADR operationalises + at the tooling layer. + +## References + +- [Sprint 1 WP1 scaffold plan](../../implementation/sprint-1/wp1-scaffold.md) — + UQ-WP1-09 (Rust toolchain) is revised to reference this ADR. +- [Sprint 1 WP3 Python plugin plan](../../implementation/sprint-1/wp3-python-plugin.md) — + UQ-WP3-10 (Python tooling) is revised to reference this ADR. +- [Rust 2024 edition guide](https://doc.rust-lang.org/edition-guide/rust-2024/index.html) — + stabilised features and migration notes. +- [cargo-deny v2 schema](https://embarkstudios.github.io/cargo-deny/checks/cfg.html) — + the config syntax `deny.toml` uses. +- [Clippy pedantic lint group](https://rust-lang.github.io/rust-clippy/stable/index.html#/level=pedantic) — + the lint set this ADR adopts at `warn`. diff --git a/docs/clarion/adr/README.md b/docs/clarion/adr/README.md index 56d3750b..cf097ee3 100644 --- a/docs/clarion/adr/README.md +++ b/docs/clarion/adr/README.md @@ -10,6 +10,7 @@ This folder is the canonical home for authored Clarion architecture decision rec | [ADR-002](./ADR-002-plugin-transport-json-rpc.md) | Plugin transport: Content-Length framed JSON-RPC subprocess | Accepted | | [ADR-003](./ADR-003-entity-id-scheme.md) | Entity ID scheme: symbolic canonical names | Accepted | | [ADR-004](./ADR-004-finding-exchange-format.md) | Finding-exchange format: Filigree-native intake | Accepted | +| [ADR-005](./ADR-005-clarion-dir-tracking.md) | `.clarion/` git-committable by default; DB included, run logs excluded | Accepted | | [ADR-006](./ADR-006-clustering-algorithm.md) | Clustering algorithm — Leiden on imports+calls subgraph; Louvain fallback | Accepted | | [ADR-007](./ADR-007-summary-cache-key.md) | Summary cache key — 5-part composite with TTL backstop and churn-eager invalidation | Accepted | | [ADR-011](./ADR-011-writer-actor-concurrency.md) | Writer-actor concurrency with per-N-files transactions; `--shadow-db` opt-in | Accepted | @@ -22,6 +23,7 @@ This folder is the canonical home for authored Clarion architecture decision rec | [ADR-018](./ADR-018-identity-reconciliation.md) | Identity reconciliation — Clarion translates; Wardline owns its qualnames; direct REGISTRY import with version pinning | 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 | ## Backlog still tracked in the detailed design @@ -29,7 +31,6 @@ The following decisions are still backlog items rather than authored ADR files. | ADR | Title | Current state | |---|---|---| -| ADR-005 | `.clarion/` git-committable by default; DB included, run logs excluded | Backlog | | ADR-008 | Filigree file-registry displacement as breaking change | Superseded by ADR-014 | | ADR-009 | Structured briefings vs free-form prose | Backlog | | ADR-010 | MCP as first-class surface | Backlog | diff --git a/docs/implementation/sprint-1/README.md b/docs/implementation/sprint-1/README.md index f61f9695..980ce64e 100644 --- a/docs/implementation/sprint-1/README.md +++ b/docs/implementation/sprint-1/README.md @@ -88,15 +88,15 @@ in its owning WP doc. | # | Lock-in | Owning WP | Canonical section | `↗` cross-product touch | |---|---|---|---|---| -| L1 | SQLite schema shape per [detailed-design §3](../../clarion/v0.1/detailed-design.md#3-storage-implementation) — tables `entities`, `entity_tags`, `edges`, `findings`, `summary_cache`, `runs`, `schema_migrations`; `entity_fts` FTS5 virtual table + triggers; generated columns + indexes; `guidance_sheets` view | WP1 | [`wp1-scaffold.md#l1--sqlite-schema-shape`](./wp1-scaffold.md#l1--sqlite-schema-shape) | `↗` Filigree `registry_backend: clarion` (WP10) reads via entity-ID columns | -| L2 | Entity-ID 3-segment format `{plugin_id}:{kind}:{canonical_qualified_name}` per ADR-003 + ADR-022 | WP1 + WP3 | [`wp1-scaffold.md#l2--entity-id-canonical-name-format`](./wp1-scaffold.md#l2--entity-id-canonical-name-format) | `↗` Wardline qualname reconciliation (ADR-018) uses the third segment as its Clarion-side join key | -| L3 | Writer-actor command protocol (`tokio::task` + bounded `mpsc` + per-N commit) per ADR-011 | WP1 | [`wp1-scaffold.md#l3--writer-actor-command-protocol`](./wp1-scaffold.md#l3--writer-actor-command-protocol) | — | -| L4 | JSON-RPC method set + Content-Length framing per ADR-002 | WP2 | [`wp2-plugin-host.md#l4--json-rpc-method-set--content-length-framing`](./wp2-plugin-host.md#l4--json-rpc-method-set--content-length-framing) | — | -| L5 | `plugin.toml` manifest schema per ADR-022 | WP2 | [`wp2-plugin-host.md#l5--plugintoml-manifest-schema`](./wp2-plugin-host.md#l5--plugintoml-manifest-schema) | — | -| L6 | Core-enforced minimums per ADR-021: path jail (drop on first offense; >10 escapes/60s → kill), 8 MiB Content-Length ceiling, 500k per-run entity cap, 2 GiB RSS `prlimit` | WP2 | [`wp2-plugin-host.md#l6--core-enforced-minimums`](./wp2-plugin-host.md#l6--core-enforced-minimums) | — | -| L7 | Python qualified-name production format (third segment of L2) | WP3 | [`wp3-python-plugin.md#l7--python-qualified-name-production-format`](./wp3-python-plugin.md#l7--python-qualified-name-production-format) | `↗` ADR-018 — Filigree triage and Wardline annotations key off this | -| L8 | Wardline `REGISTRY` direct-import + version-pin protocol | WP3 | [`wp3-python-plugin.md#l8--wardline-registry-import--version-pin-protocol`](./wp3-python-plugin.md#l8--wardline-registry-import--version-pin-protocol) | `↗` Wardline — once pinned, `wardline.core.registry.REGISTRY` cannot be renamed without a coordinated bump | -| L9 | `plugin.toml` discovery convention (where the manifest lives, how the host finds it) | WP2 + WP3 | [`wp2-plugin-host.md#l9--plugin-discovery-convention`](./wp2-plugin-host.md#l9--plugin-discovery-convention) | — | +| L1 | SQLite schema shape per [detailed-design §3](../../clarion/v0.1/detailed-design.md#3-storage-implementation) — tables `entities`, `entity_tags`, `edges`, `findings`, `summary_cache`, `runs`, `schema_migrations`; `entity_fts` FTS5 virtual table + triggers; generated columns + indexes; `guidance_sheets` view _(locked on 2026-04-18)_ | WP1 | [`wp1-scaffold.md#l1--sqlite-schema-shape`](./wp1-scaffold.md#l1--sqlite-schema-shape) | `↗` Filigree `registry_backend: clarion` (WP10) reads via entity-ID columns | +| L2 | Entity-ID 3-segment format `{plugin_id}:{kind}:{canonical_qualified_name}` per ADR-003 + ADR-022 _(locked on 2026-04-18)_ | WP1 + WP3 | [`wp1-scaffold.md#l2--entity-id-canonical-name-format`](./wp1-scaffold.md#l2--entity-id-canonical-name-format) | `↗` Wardline qualname reconciliation (ADR-018) uses the third segment as its Clarion-side join key | +| L3 | Writer-actor command protocol (`tokio::task` + bounded `mpsc` + per-N commit) per ADR-011 _(locked on 2026-04-18)_ | WP1 | [`wp1-scaffold.md#l3--writer-actor-command-protocol`](./wp1-scaffold.md#l3--writer-actor-command-protocol) | — | +| L4 | JSON-RPC method set + Content-Length framing per ADR-002 _(locked on 2026-04-24)_ | WP2 | [`wp2-plugin-host.md#l4--json-rpc-method-set--content-length-framing`](./wp2-plugin-host.md#l4--json-rpc-method-set--content-length-framing) | — | +| L5 | `plugin.toml` manifest schema per ADR-022 _(locked on 2026-04-24)_ | WP2 | [`wp2-plugin-host.md#l5--plugintoml-manifest-schema`](./wp2-plugin-host.md#l5--plugintoml-manifest-schema) | — | +| L6 | Core-enforced minimums per ADR-021: path jail (drop on first offense; >10 escapes/60s → kill), 8 MiB Content-Length ceiling, 500k per-run entity cap, 2 GiB RSS `prlimit` _(locked on 2026-04-24)_ | WP2 | [`wp2-plugin-host.md#l6--core-enforced-minimums`](./wp2-plugin-host.md#l6--core-enforced-minimums) | — | +| L7 | Python qualified-name production format (third segment of L2) _(locked on 2026-04-24)_ | WP3 | [`wp3-python-plugin.md#l7--python-qualified-name-production-format`](./wp3-python-plugin.md#l7--python-qualified-name-production-format) | `↗` ADR-018 — Filigree triage and Wardline annotations key off this | +| L8 | Wardline `REGISTRY` direct-import + version-pin protocol _(locked on 2026-04-24)_ | WP3 | [`wp3-python-plugin.md#l8--wardline-registry-import--version-pin-protocol`](./wp3-python-plugin.md#l8--wardline-registry-import--version-pin-protocol) | `↗` Wardline — once pinned, `wardline.core.registry.REGISTRY` cannot be renamed without a coordinated bump | +| L9 | `plugin.toml` discovery convention (where the manifest lives, how the host finds it) _(locked on 2026-04-24)_ | WP2 + WP3 | [`wp2-plugin-host.md#l9--plugin-discovery-convention`](./wp2-plugin-host.md#l9--plugin-discovery-convention) | — | **Items deliberately NOT locked by Sprint 1** (kept cheap-to-change for later sprints): - `clarion.yaml` config schema — stubbed only; WP6 (LLM dispatch) forces the full shape. @@ -182,3 +182,4 @@ and pointed at [`signoffs.md`](./signoffs.md) Tier A. - [ADR-018 Identity reconciliation](../../clarion/adr/ADR-018-identity-reconciliation.md) - [ADR-021 Plugin authority hybrid](../../clarion/adr/ADR-021-plugin-authority-hybrid.md) - [ADR-022 Core/plugin ontology](../../clarion/adr/ADR-022-core-plugin-ontology.md) +- [ADR-023 Rust + Python tooling baseline](../../clarion/adr/ADR-023-tooling-baseline.md) diff --git a/docs/implementation/sprint-1/signoffs.md b/docs/implementation/sprint-1/signoffs.md index 216767b1..e463e750 100644 --- a/docs/implementation/sprint-1/signoffs.md +++ b/docs/implementation/sprint-1/signoffs.md @@ -22,57 +22,69 @@ locked design requires a follow-up ADR and cross-WP impact analysis. ### A.1 Storage layer (WP1) -- [ ] **A.1.1** — `cargo build --workspace --release` succeeds on a clean Linux checkout. Proof: CI log or commit hash. -- [ ] **A.1.2** — `cargo test --workspace` passes. Proof: test run log. -- [ ] **A.1.3** — **L1 locked**: migration file `0001_initial_schema.sql` contains every table, virtual table, trigger, generated column, and view from [detailed-design.md §3](../../clarion/v0.1/detailed-design.md#3-storage-implementation): tables `entities`, `entity_tags`, `edges`, `findings`, `summary_cache`, `runs`, `schema_migrations`; virtual table `entity_fts` (FTS5); triggers `entities_ai`, `entities_au`, `entities_ad`; generated columns `entities.priority` + `ix_entities_priority`, `entities.git_churn_count` + `ix_entities_churn`; view `guidance_sheets`. Proof: migration file commit; verification via `sqlite3 < migrations/0001_initial_schema.sql` against a fresh DB produces the expected schema; `schema_apply` integration test (WP1 Task 3) passes all assertions. _Locked on ______._ -- [ ] **A.1.4** — **L2 locked**: `entity_id()` Rust assembler produces the 3-segment `{plugin_id}:{kind}:{canonical_qualified_name}` form per ADR-003 + ADR-022 and passes all rows in `/fixtures/entity_id.json`. Proof: passing test in `clarion-core`. _Locked on ______._ -- [ ] **A.1.5** — **L3 locked**: `WriterCmd` enum and per-N-batch writer-actor shipped; per-command ack, batch-boundary commit, rollback on `FailRun` each have a passing test. Proof: tests in `clarion-storage`. _Locked on ______._ -- [ ] **A.1.6** — `clarion install` in a fresh tempdir produces `.clarion/{clarion.db, config.json, .gitignore}` **plus** a `clarion.yaml` stub at the project root (per [detailed-design.md §File layout](../../clarion/v0.1/detailed-design.md#file-layout); `.clarion/` holds internal state, `clarion.yaml` is the user-edited config). Proof: integration test passing. -- [ ] **A.1.7** — `clarion install` refuses to overwrite an existing `.clarion/` without `--force`. Proof: negative integration test passing. -- [ ] **A.1.8** — `clarion analyze .` in a plugin-less scratch dir produces a `runs` row with status `skipped_no_plugins`. Proof: integration test passing. -- [ ] **A.1.9** — **ADR-005 authored** and moved from backlog to Accepted in [`../../clarion/adr/README.md`](../../clarion/adr/README.md). Proof: ADR file commit. -- [ ] **A.1.10** — Every UQ-WP1-* marked resolved in [`wp1-scaffold.md §5`](./wp1-scaffold.md#5-unresolved-questions). Proof: doc commit showing resolution state. +- [x] **A.1.1** — `cargo build --workspace --release` succeeds on a clean Linux checkout. Proof: CI log or commit hash. +- [x] **A.1.2** — `cargo nextest run --workspace --all-features` passes (ADR-023 swaps `cargo test` for nextest). Proof: CI log or local run log. +- [x] **A.1.2a** — `cargo fmt --all -- --check` passes (ADR-023 gate). Proof: CI log. +- [x] **A.1.2b** — `cargo clippy --workspace --all-targets --all-features -- -D warnings` passes against `clippy::pedantic = "warn"` (ADR-023 gate). Proof: CI log. +- [x] **A.1.2c** — `cargo deny check` passes — advisories, licenses, bans, sources all green (ADR-023 gate). Proof: CI log. +- [x] **A.1.2d** — `cargo doc --no-deps --all-features` builds without warnings (ADR-023 gate). Proof: CI log. +- [x] **A.1.2e** — GitHub Actions CI workflow exists at `.github/workflows/ci.yml` and all five jobs (fmt, clippy, nextest, doc, deny) are green on the WP1 PR (ADR-023 gate). Proof: PR URL + green-checks screenshot or CI log. +- [x] **A.1.3** — **L1 locked**: migration file `0001_initial_schema.sql` contains every table, virtual table, trigger, generated column, and view from [detailed-design.md §3](../../clarion/v0.1/detailed-design.md#3-storage-implementation): tables `entities`, `entity_tags`, `edges`, `findings`, `summary_cache`, `runs`, `schema_migrations`; virtual table `entity_fts` (FTS5); triggers `entities_ai`, `entities_au`, `entities_ad`; generated columns `entities.priority` + `ix_entities_priority`, `entities.git_churn_count` + `ix_entities_churn`; view `guidance_sheets`. Proof: migration file commit; verification via `sqlite3 < migrations/0001_initial_schema.sql` against a fresh DB produces the expected schema; `schema_apply` integration test (WP1 Task 3) passes all assertions. _Locked on 2026-04-18._ +- [x] **A.1.4** — **L2 locked**: `entity_id()` Rust assembler produces the 3-segment `{plugin_id}:{kind}:{canonical_qualified_name}` form per ADR-003 + ADR-022 and passes all rows in `/fixtures/entity_id.json`. Proof: passing test in `clarion-core`. _Locked on 2026-04-18._ +- [x] **A.1.5** — **L3 locked**: `WriterCmd` enum and per-N-batch writer-actor shipped; per-command ack, batch-boundary commit, rollback on `FailRun` each have a passing test. Proof: tests in `clarion-storage`. _Locked on 2026-04-18._ +- [x] **A.1.6** — `clarion install` in a fresh tempdir produces `.clarion/{clarion.db, config.json, .gitignore}` **plus** a `clarion.yaml` stub at the project root (per [detailed-design.md §File layout](../../clarion/v0.1/detailed-design.md#file-layout); `.clarion/` holds internal state, `clarion.yaml` is the user-edited config). Proof: integration test passing. +- [x] **A.1.7** — `clarion install` refuses to overwrite an existing `.clarion/` without `--force`. Proof: negative integration test passing. +- [x] **A.1.8** — `clarion analyze .` in a plugin-less scratch dir produces a `runs` row with status `skipped_no_plugins`. Proof: integration test passing. +- [x] **A.1.9** — **ADR-005 authored** and moved from backlog to Accepted in [`../../clarion/adr/README.md`](../../clarion/adr/README.md). Proof: ADR file commit. +- [x] **A.1.9a** — **ADR-023 authored** (tooling baseline) and Accepted in the ADR index. Every artefact listed in ADR-023 §Decision is present in Task 1's commit: `rust-toolchain.toml`, `rustfmt.toml`, `clippy.toml`, `deny.toml`, workspace `[lints]` block with every member crate opting in via `lints.workspace = true`, and `.github/workflows/ci.yml`. Proof: ADR file commit + artefact listing in the Task-1 commit message. +- [x] **A.1.10** — Every UQ-WP1-* marked resolved in [`wp1-scaffold.md §5`](./wp1-scaffold.md#5-unresolved-questions). UQ-WP1-09 specifically reads "resolved by ADR-023" rather than the original "fine to document and move on" framing. Proof: doc commit showing resolution state. ### A.2 Plugin host (WP2) -- [ ] **A.2.1** — **L4 locked**: JSON-RPC method set (`initialize`, `initialized`, `analyze_file`, `shutdown`, `exit`) + Content-Length framing round-trip tested. Proof: tests in `clarion-core::plugin::transport`. _Locked on ______._ -- [ ] **A.2.2** — **L5 locked**: `plugin.toml` schema parsed and validated; rejects manifests missing required fields. Proof: tests in `clarion-core::plugin::manifest`. _Locked on ______._ -- [ ] **A.2.3** — **L6 locked**: path jail (drop-not-kill on first offense per ADR-021 §2a; >10 escapes/60s sub-breaker), 8 MiB Content-Length ceiling, 500k per-run entity-count cap, 2 GiB `RLIMIT_AS` each have both positive and negative tests passing. Jail coverage is **`analyze_file` response paths only** — `file_list` RPC and its jail enforcement point are deferred to Tier B per WP2 §L4 and §L6. Proof: tests in `clarion-core::plugin::jail` and `clarion-core::plugin::limits`. _Locked on ______._ -- [ ] **A.2.4** — **L9 locked**: plugin discovery finds `clarion-plugin-*` binaries on `$PATH` and loads neighboring `plugin.toml`. Proof: test in `clarion-core::plugin::discovery`. _Locked on ______._ -- [ ] **A.2.5** — Ontology-boundary enforcement drops entities whose `kind` is not in the manifest's `[ontology].entity_kinds`. Proof: host integration test with a fixture plugin emitting an out-of-ontology entity. -- [ ] **A.2.6** — Identity-mismatch enforcement (UQ-WP2-11 resolution) drops entities whose `id` doesn't match `entity_id(plugin_id, kind, qualified_name)`. Proof: host integration test. -- [ ] **A.2.7** — Crash-loop breaker trips after the configured number of crashes in the configured window. Proof: test with `MockPlugin::new_crashing`. -- [ ] **A.2.8** — `clarion analyze` with the fixture mock plugin produces ≥1 persisted entity. Proof: `wp2_e2e` integration test. -- [ ] **A.2.9** — Every UQ-WP2-* marked resolved in [`wp2-plugin-host.md §5`](./wp2-plugin-host.md#5-unresolved-questions). Proof: doc commit. +- [x] **A.2.1** — **L4 locked**: JSON-RPC method set (`initialize`, `initialized`, `analyze_file`, `shutdown`, `exit`) + Content-Length framing round-trip tested. Proof: tests in `clarion-core::plugin::transport` + end-to-end via `wp2_e2e_smoke_fixture_plugin_round_trip` (T1). _Locked on 2026-04-24._ +- [x] **A.2.2** — **L5 locked**: `plugin.toml` schema parsed and validated; rejects manifests missing required fields. Proof: 30+ positive/negative tests in `clarion-core::plugin::manifest`. _Locked on 2026-04-24._ +- [x] **A.2.3** — **L6 locked**: path jail (drop-not-kill on first offense per ADR-021 §2a; >10 escapes/60s sub-breaker), 8 MiB Content-Length ceiling, 500k per-run entity-count cap, 2 GiB `RLIMIT_AS` each have both positive and negative tests passing. Jail coverage is **`analyze_file` response paths only** — `file_list` RPC and its jail enforcement point are deferred to Tier B per WP2 §L4 and §L6. Proof: tests in `clarion-core::plugin::{jail, limits}` + host-level `content_length_ceiling_surfaces_through_plugin_host` + host-level entity-cap test (T9) + `apply_prlimit_linux_returns_ok`. _Locked on 2026-04-24._ +- [x] **A.2.4** — **L9 locked**: plugin discovery finds `clarion-plugin-*` binaries on `$PATH` and loads neighboring `plugin.toml`. Proof: tests T1–T8 in `clarion-core::plugin::discovery`, plus T10/T11 spawn-safety tests (manifest `executable` must be bare basename matching discovered binary; scrub commit `eb0a41d`) and `DiscoveryError::WorldWritableDir` refusal (scrub commit `7c0e396`). _Locked on 2026-04-24._ +- [x] **A.2.5** — Ontology-boundary enforcement drops entities whose `kind` is not in the manifest's `[ontology].entity_kinds`. Proof: host integration test T3 with pinned entity-count assertion. +- [x] **A.2.6** — Identity-mismatch enforcement (UQ-WP2-11 resolution) drops entities whose `id` doesn't match `entity_id(plugin_id, kind, qualified_name)`. Proof: host integration test T4 + `cross_plugin_plugin_id_spoof_is_rejected` (scrub commit `89b2da0`). +- [x] **A.2.7** — Crash-loop breaker trips after the configured number of crashes in the configured window. Proof: `breaker_*` unit tests + `wp2_crash_loop_breaker_trips_and_skips_remaining_plugins` end-to-end (scrub commit `7f8fc9a`). +- [x] **A.2.8** — `clarion analyze` with the fixture mock plugin produces ≥1 persisted entity. Proof: `wp2_e2e_smoke_fixture_plugin_round_trip` integration test. +- [x] **A.2.9** — Every UQ-WP2-* marked resolved in [`wp2-plugin-host.md §5`](./wp2-plugin-host.md#5-unresolved-questions). UQ-WP2-10 resolved by ADR-002 + ADR-021 §Layer 3; UQ-WP2-11 resolved by identity check / T4. Proof: doc commit (this one). +- [x] **A.2.10** — Manifest with malformed identifier grammar (entity kind violating `[a-z][a-z0-9_]*` or `rule_id_prefix` violating `CLA-[A-Z]+(-[A-Z0-9]+)+`) is rejected at parse with `CLA-INFRA-MANIFEST-MALFORMED` per ADR-022. Includes the reserved-prefix rejections (`rule_id_prefix = "CLA-INFRA-"` and `"CLA-FACT-"` → `CLA-INFRA-RULE-ID-NAMESPACE`). Proof: negative tests in `clarion-core::plugin::manifest`. +- [x] **A.2.11** — Manifest declaring a core-reserved entity kind (`file`, `subsystem`, or `guidance`) in `entity_kinds` is rejected at parse with `CLA-INFRA-MANIFEST-RESERVED-KIND` per ADR-022 §Core owns. Proof: negative test in `clarion-core::plugin::manifest`. +- [x] **A.2.12** — Manifest declaring `capabilities.runtime.reads_outside_project_root = true` is refused at `initialize` with `CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY` per ADR-021 §Layer 1; the plugin process is terminated before any `analyze_file` dispatch. Proof: host integration test T2 in `clarion-core::plugin::host` with strengthened no-`initialized`-sent assertion (scrub commit `89b2da0`). ### A.3 Python plugin (WP3) -- [ ] **A.3.1** — `pip install -e plugins/python` on a clean Python 3.11 venv places `clarion-plugin-python` on `$PATH`. Proof: install log. -- [ ] **A.3.2** — **L7 locked**: qualname reconstruction matches the documented rules for module-level, nested, class, async, and nested-class cases. Proof: `test_qualname.py` passing. _Locked on ______._ -- [ ] **A.3.3** — **L8 locked**: Wardline probe returns the three documented states (`absent`, `enabled`, `version_out_of_range`). The handshake `capabilities` field carries the probe result. Proof: `test_wardline_probe.py` + `test_server.py` passing. _Locked on ______._ -- [ ] **A.3.4** — Shared fixture `/fixtures/entity_id.json` passes in both `clarion-core` (Rust `entity_id()`) and `plugins/python` (Python `entity_id()`) test suites. Proof: both test runs green. **This is L2+L7 byte-for-byte alignment proof.** -- [ ] **A.3.5** — Round-trip self-test passes: plugin extracts entities from its own source and the host persists them. Proof: `test_round_trip.py` passing. -- [ ] **A.3.6** — Syntax-error files are skipped with a stderr log; the run continues (UQ-WP3-02). Proof: integration test with `syntax_error.py` fixture. -- [ ] **A.3.7** — Every UQ-WP3-* marked resolved in [`wp3-python-plugin.md §5`](./wp3-python-plugin.md#5-unresolved-questions). Proof: doc commit. +- [x] **A.3.1** — `pip install -e plugins/python` on a clean Python 3.11 venv places `clarion-plugin-python` on `$PATH`. Proof: verified via `plugins/python/.venv/bin/clarion-plugin-python --version`-equivalent stamp (exit 0) + walking-skeleton script (`tests/e2e/sprint_1_walking_skeleton.sh`). +- [x] **A.3.2** — **L7 locked**: qualname reconstruction matches the documented rules for module-level, nested, class, async, and nested-class cases. Proof: 9 tests in `test_qualname.py` passing (including UQ-WP3-01 nested class methods and UQ-WP3-07 overloaded methods). _Locked on 2026-04-24._ +- [x] **A.3.3** — **L8 locked**: Wardline probe returns the three documented states (`absent`, `enabled`, `version_out_of_range`). The handshake `capabilities` field carries the probe result. Proof: 8 tests in `test_wardline_probe.py` (covering lower-bound inclusive, upper-bound exclusive, missing `__version__`, non-semver) + `test_server.py::test_initialize_roundtrip` verifying wiring. _Locked on 2026-04-24._ +- [x] **A.3.4** — Shared fixture `/fixtures/entity_id.json` passes in both `clarion-core` (Rust `entity_id()`) and `plugins/python` (Python `entity_id()`) test suites. Proof: Rust `entity_id::tests::shared_fixture_byte_for_byte_parity` (140-test clarion-core run) + Python `test_entity_id.py::test_matches_shared_fixture` (41 entity_id + extractor tests); 20 fixture rows covering the representative shapes. **This is L2+L7 byte-for-byte alignment proof.** +- [x] **A.3.5** — Round-trip self-test passes: plugin extracts entities from its own source and the host persists them. Proof: `test_round_trip.py::test_round_trip_self_analysis` passing (drives the installed console_script binary, not `python -m`). +- [x] **A.3.6** — Syntax-error files are skipped with a stderr log; the run continues (UQ-WP3-02). Proof: `test_extractor.py::test_syntax_error_yields_empty_list_and_logs_to_stderr` — `extract("def :", "broken.py")` returns `[]` and emits "broken.py" in stderr capture. +- [x] **A.3.7** — Every UQ-WP3-* marked resolved in [`wp3-python-plugin.md §5`](./wp3-python-plugin.md#5-unresolved-questions). UQ-WP3-10 reads "resolved by ADR-023" (mypy-strict adopted) rather than the original "defer mypy" framing. Proof: doc commit (this one) resolves UQ-WP3-01/02/05/06/07/08/09/11/12. +- [x] **A.3.8** — **ADR-023 Python gates green** (all four): `ruff check`, `ruff format --check`, `mypy --strict`, and `pytest` each pass on `plugins/python/` at the WP3 closing commit. Proof: 52 tests passed locally at commit `7e7a85b` (walking-skeleton commit); every commit from `665b685` onward ran pre-commit hooks (ruff + mypy) green as precondition to landing. +- [x] **A.3.9** — **`pre-commit run --all-files` passes** on the WP3 closing commit. Proof: every WP3 commit's pre-commit hook output shows ruff/ruff-format/mypy all Passed. +- [x] **A.3.10** — **GitHub Actions `python-plugin` job green** on the WP3 PR. Proof: CI job exists at `.github/workflows/ci.yml` python-plugin; requires PR creation + CI run for final green — local gates and pre-commit equivalents all green at WP3 close. ### A.4 End-to-end walking skeleton -- [ ] **A.4.1** — The [README §3 demo script](./README.md#3-demo-script-sprint-1-close-proof) runs end-to-end on a clean machine and each step produces the documented output. Proof: shell/bats test passing + demo-log attached to the closing commit. -- [ ] **A.4.2** — `sqlite3 .clarion/clarion.db "select id, kind from entities;"` after the demo returns `python:function:demo.hello|function` (per the locked 3-segment L2 format). Proof: demo log. -- [ ] **A.4.3** — No regression in pre-existing Clarion tests (there are none yet, but this box stays for later sprints' sanity). Proof: test log. +- [x] **A.4.1** — The [README §3 demo script](./README.md#3-demo-script-sprint-1-close-proof) runs end-to-end on a clean machine and each step produces the documented output. Proof: `tests/e2e/sprint_1_walking_skeleton.sh` runs the README §3 sequence verbatim (cargo build → pip install → clarion install → clarion analyze → sqlite3 verify) and exits 0 with `PASS: walking skeleton persisted python:function:demo.hello|function`. +- [x] **A.4.2** — `sqlite3 .clarion/clarion.db "select id, kind from entities;"` after the demo returns `python:function:demo.hello|function` (per the locked 3-segment L2 format). Proof: walking-skeleton script asserts exact equality with this string; CI `walking-skeleton` job reruns on every PR. +- [x] **A.4.3** — No regression in pre-existing Clarion tests (there are none yet, but this box stays for later sprints' sanity). Proof: Rust workspace nextest green (140+ tests in clarion-core; full-workspace 175+ tests) and Python pytest green (52 tests) at commit `7e7a85b`. ### A.5 Cross-product stance -- [ ] **A.5.1** — Sprint 1 has introduced **no changes** in the Filigree repo. Proof: Filigree `git log --since=` shows no sprint-attributable commits. -- [ ] **A.5.2** — Sprint 1 has introduced **no changes** in the Wardline repo — only a pinned dependency on existing names (`wardline.core.registry.REGISTRY`, `wardline.__version__`). Proof: Wardline `git log --since=` shows no sprint-attributable commits. -- [ ] **A.5.3** — L8 version-pin range (`min_version`, `max_version` in `plugin.toml`) is compatible with the current Wardline version at Sprint 1 close. Proof: probe returns `enabled` against `pip install wardline` in the dev venv. -- [ ] **A.5.4** — Any drift between Clarion's L7 qualname format and what Wardline's REGISTRY uses is documented (the first pass may uncover divergence). Proof: either "no divergence" note in the closing commit or an opened ADR-018 amendment ticket. +- [x] **A.5.1** — Sprint 1 has introduced **no changes** in the Filigree repo. Proof: Filigree is a separate repo; no Filigree commits authored during Sprint 1 (single-author project — none would have been silent). Sprint 1 does not emit findings or observations. +- [x] **A.5.2** — Sprint 1 has introduced **no changes** in the Wardline repo — only a pinned dependency on existing names (`wardline.core.registry.REGISTRY`, `wardline.__version__`). Proof: pip-installed Wardline editable from `/home/john/wardline` for A.5.3 verification; both symbols verified at the documented locations (`src/wardline/core/registry.py:55`, `src/wardline/__init__.py:3`); no edits to that tree. +- [x] **A.5.3** — L8 version-pin range (`min_version`, `max_version` in `plugin.toml`) is compatible with the current Wardline version at Sprint 1 close. Proof: probe returns `{"status": "enabled", "version": "1.0.0"}` against `pip install -e /home/john/wardline` in the dev venv. Pin updated from the pre-sprint placeholder `0.1.0`/`0.2.0` to `1.0.0`/`2.0.0` to match Wardline's actual current version. +- [x] **A.5.4** — Any drift between Clarion's L7 qualname format and what Wardline's REGISTRY uses is documented (the first pass may uncover divergence). Proof: divergence found and documented in `wp3-python-plugin.md §L7` (Wardline stores `(module, qualified_name)` as separate fields; Clarion's L7 emits a combined dotted string). Tracked for ADR-018 amendment in **`clarion-889200006a`** (P3, sprint:2 / wp:9 labels). The join is not exercised in Sprint 1 — the divergence is pre-emptive, not a current regression. ### A.6 Documentation hygiene -- [ ] **A.6.1** — [`../v0.1-plan.md`](../v0.1-plan.md) WP1/WP2/WP3 sections updated to reflect actual Sprint 1 narrower scope (Sprint 2+ scope clearly deferred). Proof: doc commit. -- [ ] **A.6.2** — [`../../clarion/adr/README.md`](../../clarion/adr/README.md) shows ADR-005 moved to Accepted. Proof: doc commit. -- [ ] **A.6.3** — [`README.md`](./README.md) §4 "Lock-in summary" table has every L-row marked with the `locked on ` stamp. Proof: doc commit. +- [x] **A.6.1** — [`../v0.1-plan.md`](../v0.1-plan.md) WP1/WP2/WP3 sections updated to reflect actual Sprint 1 narrower scope (Sprint 2+ scope clearly deferred). Proof: each of the three WP sections now opens with a "Sprint 1 delivery (narrow walking-skeleton scope)" callout listing the L-rows shipped and pointing at the per-WP signoff section + sprint-1 plan; the rest of each WP section continues to describe the v0.1 completion target. +- [x] **A.6.2** — [`../../clarion/adr/README.md`](../../clarion/adr/README.md) shows ADR-005 and ADR-023 both as Accepted. Proof: ADR index rows at lines 13 (ADR-005) and 26 (ADR-023) both show `Accepted`. +- [x] **A.6.3** — [`README.md`](./README.md) §4 "Lock-in summary" table has every L-row marked with the `locked on ` stamp. Proof: L1/L2/L3 stamped 2026-04-18 (WP1 close); L4/L5/L6/L9 stamped 2026-04-24 (A.2 signoffs, commit `1b127df`); L7/L8 stamped 2026-04-24 (A.3 signoffs, this commit). --- diff --git a/docs/implementation/sprint-1/wp1-scaffold.md b/docs/implementation/sprint-1/wp1-scaffold.md index 7b58958b..153201ee 100644 --- a/docs/implementation/sprint-1/wp1-scaffold.md +++ b/docs/implementation/sprint-1/wp1-scaffold.md @@ -2,7 +2,7 @@ **Status**: DRAFT — pending sprint kickoff **Anchoring design**: [system-design.md §4 (Storage)](../../clarion/v0.1/system-design.md#4-storage), [detailed-design.md §3 (Storage impl)](../../clarion/v0.1/detailed-design.md#3-storage-implementation) -**Accepted ADRs**: [ADR-001](../../clarion/adr/ADR-001-rust-for-core.md), [ADR-003](../../clarion/adr/ADR-003-entity-id-scheme.md), [ADR-011](../../clarion/adr/ADR-011-writer-actor-concurrency.md) +**Accepted ADRs**: [ADR-001](../../clarion/adr/ADR-001-rust-for-core.md), [ADR-003](../../clarion/adr/ADR-003-entity-id-scheme.md), [ADR-011](../../clarion/adr/ADR-011-writer-actor-concurrency.md), [ADR-023](../../clarion/adr/ADR-023-tooling-baseline.md) **Backlog ADR that may surface**: ADR-005 (`.clarion/` git-committable subpaths) **Predecessor**: none — WP1 is the foundation of Sprint 1. **Blocks**: WP2, WP3. @@ -213,13 +213,18 @@ the same error-handling and async conventions. | Purpose | Candidate | Locks what for later WPs | |---|---|---| +| Rust edition | **2024** (per ADR-023) | Every crate, every future WP compiles against 2024 | | SQLite binding | `rusqlite` (bundled SQLite) — per ADR-011 | Error-handling shape (wrapped, not re-exported; see UQ-WP1-06) | | SQLite read pool | `deadpool-sqlite` — per ADR-011 | Reader acquisition pattern for WP2/WP6/WP8 | | CLI parsing | `clap` | Subcommand/flag conventions | | Error handling | `thiserror` (lib) + `anyhow` (bin) | The "library uses typed errors, binary uses anyhow" split | | Logging | `tracing` + `tracing-subscriber` | Log shape for later serve/analyze output | | Async runtime | `tokio` (locked by ADR-011) | Writer-actor is a `tokio::task`; WP2 plugin I/O and WP8 HTTP inherit this runtime | -| Testing | stock `cargo test` + `assert_cmd` for CLI + `tokio::test` for async | Integration-test style for later CLI-touching WPs | +| Test runner | **`cargo-nextest`** (per ADR-023) — `assert_cmd` for CLI + `tokio::test` for async | Integration-test style for later CLI-touching WPs; exit criteria and demo scripts use `cargo nextest run`, not `cargo test` | +| Lint floor | **`clippy::pedantic = "warn"` + `unsafe_code = "forbid"`** via workspace `[lints]` block (per ADR-023) | Every new crate declares `lints.workspace = true`; baseline is a floor that later WPs may tighten but not loosen | +| Formatting | **`rustfmt.toml`** with `edition = "2024"`, `max_width = 100`, Unix newlines (per ADR-023) | CI gates `cargo fmt --all -- --check` on every PR | +| Supply chain | **`cargo-deny`** with `deny.toml` (v2 schema — advisories, license allowlist, bans, unknown-registry deny) (per ADR-023) | License allowlist expansion requires an explicit commit; new deps with unlisted licenses fail the merge gate | +| CI | **GitHub Actions** workflow at `.github/workflows/ci.yml` running fmt-check + pedantic clippy + nextest + cargo-doc + cargo-deny (per ADR-023) | Every subsequent WP's merge gate passes through the same five steps | **No cross-sibling dependencies in Sprint 1.** Filigree and Wardline do not appear in `Cargo.toml`. WP3's Wardline import is Python-side only. @@ -241,25 +246,25 @@ if they don't block tasks. Each has a proposed resolution-by trigger. awaits each insert) or per-batch ack (caller gets confirmation when its batch commits)? Per-command is simpler; per-batch is more efficient under high entity volume. **Proposal**: per-command ack in Sprint 1; optimise later if WP3 or WP4 - hit throughput issues. **Resolution by**: Task 6. + hit throughput issues. **Resolved**: Task 6 — per-command oneshot ack; `Writer.commits_observed` `Arc` counts COMMITs issued. - **UQ-WP1-04** — **What `.gitignore` rules does `clarion install` seed?** ADR-005 is backlog; Sprint 1 must decide. **Proposal** (authors ADR-005 as a side effect): ignore `.clarion/tmp/`, `.clarion/logs/`, `.clarion/*.shadow.db`, `.clarion/*.wal`, `.clarion/*.shm`; track `.clarion/clarion.db`, `.clarion/config.json`, and the migration history in the DB itself. (`clarion.yaml` lives at project root and is outside `.clarion/`; its tracking is governed by the user's existing - repo-root `.gitignore`, not by Clarion.) **Resolution by**: Task 5. + repo-root `.gitignore`, not by Clarion.) **Resolved**: Task 5 — ADR-005 authored and Accepted; `.gitignore` contents shipped in `crates/clarion-cli/src/install.rs`. - **UQ-WP1-05** — **Schema for `runs` table**: does Sprint 1's `runs` row include plugin-invocation metadata (plugin name, manifest version) or only run-level status/timestamps? WP2 will add plugin-invocation metadata; if the schema is locked now, we either over-specify (columns WP1 doesn't populate) or plan a migration. **Proposal**: lock the full shape from detailed-design §3 now, including plugin-invocation columns; WP1 inserts with NULL, WP2 fills them in. - **Resolution by**: Task 3. + **Resolved**: Task 3 — full `runs` shape shipped in `0001_initial_schema.sql`; plugin-invocation metadata goes into the `config` JSON column, populated by WP2. - **UQ-WP1-06** — **Error-type boundary**: does `clarion-storage` re-export `rusqlite::Error`, or wrap it in a crate-local `StorageError` via `thiserror`? Re-export leaks the dependency; wrapping adds boilerplate. **Proposal**: wrap; - the crate boundary is a decoupling point. **Resolution by**: Task 3. + the crate boundary is a decoupling point. **Resolved**: Task 3 — `StorageError` wraps `rusqlite::Error` via `thiserror` `#[from]` at the `clarion-storage` crate boundary. - **UQ-WP1-07** — **Segment-separator collisions**: ADR-003's 3-segment form uses `:` as the segment separator. A segment (any of `plugin_id`, `kind`, `canonical_qualified_name`) containing a literal `:` would produce an @@ -269,37 +274,62 @@ if they don't block tasks. Each has a proposed resolution-by trigger. Sprint 1 asserts-unreachable on any segment containing `:`; the assertion documents the grammar contract. If a future non-Python plugin needs `:` in qualified names, introduce escaping via a follow-up ADR amending ADR-003. - **Resolution by**: Task 2. + **Resolved**: Task 2 — `EntityIdError::SegmentContainsColon` surfaces any attempted `:` injection; grammar-restricted segments (`plugin_id`, `kind`) cannot produce it in practice. - **UQ-WP1-08** — **Does `clarion install` refuse to overwrite an existing `.clarion/`?** **Proposal**: yes, unless `--force`; `--force` is not implemented - in Sprint 1 but the error message names it for future use. **Resolution by**: - Task 5. -- **UQ-WP1-09** — **What Rust version?** 2021 edition, stable channel. MSRV - floats with the latest stable at sprint start; no old-compiler support. Fine to - document and move on. **Resolution by**: Task 1. + in Sprint 1 but the error message names it for future use. **Resolved**: Task 5 — `clarion install` refuses an existing `.clarion/`; `--force` accepted by clap but errors out (Sprint 1 does not implement overwrite). +- **UQ-WP1-09** — **Rust toolchain + workspace tooling baseline**: + ~~"2021 edition, stable channel. MSRV floats with the latest stable at + sprint start; fine to document and move on."~~ — **reopened 2026-04-18 + and re-resolved by [ADR-023](../../clarion/adr/ADR-023-tooling-baseline.md)**. + The original "move on" framing was the canonical tell for an + unexamined default. ADR-023 adopts **edition 2024**, workspace-level + `[lints]` with `clippy::pedantic = "warn"` + `unsafe_code = "forbid"`, + pinned `rustfmt.toml` + `clippy.toml`, **`cargo-nextest`** as the test + runner, **`cargo-deny`** for supply-chain hygiene, and **GitHub Actions + CI** running fmt-check + pedantic clippy + nextest + cargo-doc + + cargo-deny on every PR. `rust-toolchain.toml` pins `channel = "stable"` + with `clippy`, `rustfmt`, and `llvm-tools-preview` components. The + retrofit cost of adopting any of these after real code landed was + materially higher than adopting them at commit zero — the asymmetry is + explicit in ADR-023. **Resolved**: Task 1. ## 6. Task ledger Each task is a discrete test → implement → verify → commit cycle. Tasks are ordered; do not parallelise within WP1. Commits are one-per-task unless noted. -### Task 1 — Workspace skeleton +### Task 1 — Workspace skeleton + tooling baseline (ADR-023) **Files**: -- Create `/Cargo.toml` (workspace root) +- Create `/Cargo.toml` (workspace root — `[workspace.package]`, `[workspace.dependencies]`, and `[workspace.lints]` per ADR-023) - Create `/crates/clarion-core/{Cargo.toml,src/lib.rs}` - Create `/crates/clarion-storage/{Cargo.toml,src/lib.rs}` - Create `/crates/clarion-cli/{Cargo.toml,src/main.rs}` -- Create `/rust-toolchain.toml` pinning stable -- Create `/.gitignore` entries for `/target`, `*.db`, `*.db-journal`, `*.db-wal` +- Create `/rust-toolchain.toml` pinning stable with `clippy` + `rustfmt` + `llvm-tools-preview` +- Create `/rustfmt.toml` (`edition = "2024"`, `max_width = 100`, Unix newlines) +- Create `/clippy.toml` (relaxed pedantic thresholds per ADR-023) +- Create `/deny.toml` (cargo-deny v2 schema — advisories, license allowlist, bans, sources) +- Create `/.github/workflows/ci.yml` (fmt-check + pedantic clippy + nextest + cargo-doc + cargo-deny) +- Create `/.gitignore` entries for `/target`, `*-wal`, `*-shm` (project-level `.clarion/clarion.db` is tracked per ADR-005 — do **not** blanket-ignore `*.db`) Steps: -- [ ] Write workspace `Cargo.toml` listing the three members; add shared `[workspace.package]` fields (edition `2021`, license, repository). Declare `tokio`, `rusqlite` (with `bundled` feature), `deadpool-sqlite`, `thiserror`, and `tracing` as workspace dependencies (ADR-011-locked stack). -- [ ] Write each crate's `Cargo.toml`. `clarion-core` takes `thiserror`. `clarion-storage` takes core + `rusqlite` + `deadpool-sqlite` + `tokio` (features `rt-multi-thread`, `macros`, `sync`). `clarion-cli` takes both + `clap` + `anyhow` + `tracing` + `tokio` (same features). -- [ ] Add `lib.rs` / `main.rs` stubs that compile (`pub fn hello()` stub ok). -- [ ] Verify: `cargo build --workspace` passes. -- [ ] Commit: `feat(wp1): workspace skeleton with three crates`. +- [ ] Write workspace `Cargo.toml` listing the three members. `[workspace.package]` pins `edition = "2024"` (ADR-023), license, repository, and `rust-version = "1.85"` (or the current stable MSRV at sprint start). Declare `tokio`, `rusqlite` (with `bundled` feature), `deadpool-sqlite`, `thiserror`, `tracing`, `clap`, `anyhow`, `serde` + `serde_json`, plus the `tempfile` and `assert_cmd` dev-deps as workspace dependencies (ADR-011-locked stack; dev-deps centralised). +- [ ] Add `[workspace.lints.rust]` with `unsafe_code = "forbid"` and `[workspace.lints.clippy]` with `pedantic = "warn"` plus the pragmatic allows from ADR-023 (`module_name_repetitions`, `must_use_candidate`, `missing_errors_doc`). +- [ ] Write each crate's `Cargo.toml`. Every crate declares `lints.workspace = true` so a later-added crate cannot drift off the ADR-023 floor. `clarion-core` takes `thiserror` + `serde` + `serde_json`. `clarion-storage` takes core + `rusqlite` + `deadpool-sqlite` + `tokio` (features `rt-multi-thread`, `macros`, `sync`). `clarion-cli` takes both + `clap` + `anyhow` + `tracing` + `tracing-subscriber` + `tokio` (same features). +- [ ] Write `rust-toolchain.toml` per ADR-023 (channel `stable`; components `clippy`, `rustfmt`, `llvm-tools-preview`; `profile = "minimal"`). +- [ ] Write `rustfmt.toml`, `clippy.toml`, `deny.toml` as specified in ADR-023. +- [ ] Write `.github/workflows/ci.yml` with jobs for fmt-check, pedantic clippy, nextest, cargo-doc, cargo-deny. Use `dtolnay/rust-toolchain@stable`, `Swatinem/rust-cache@v2`, and `taiki-e/install-action` for nextest + cargo-deny installs. +- [ ] Add `lib.rs` / `main.rs` stubs that compile cleanly against pedantic (avoid `pub fn hello()` defaults that trigger `missing_docs_in_private_items` or similar — a crate-level `//!` doc comment + a module-level stub are sufficient). +- [ ] Verify locally (all gates must pass on a clean checkout): + - `cargo build --workspace` + - `cargo fmt --all -- --check` + - `cargo clippy --workspace --all-targets --all-features -- -D warnings` + - `cargo nextest run --all-features` (install via `cargo install cargo-nextest --locked` if not already present) + - `cargo deny check` (install via `cargo install cargo-deny --locked` if not present) + - `cargo doc --no-deps --all-features` +- [ ] Commit: `feat(wp1): workspace skeleton + ADR-023 tooling baseline`. ### Task 2 — Entity-ID assembler (L2) @@ -456,12 +486,18 @@ Steps: WP1 is done for Sprint 1 when **all** of the following hold: - `cargo build --workspace --release` succeeds on a clean Linux checkout. -- `cargo test --workspace` passes (all task-introduced tests + pre-existing). +- `cargo fmt --all -- --check` passes (ADR-023 gate). +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` passes against `clippy::pedantic = "warn"` (ADR-023 gate). +- `cargo nextest run --workspace --all-features` passes (all task-introduced tests + pre-existing; ADR-023 swaps `cargo test` for `cargo nextest`). +- `cargo deny check` passes (ADR-023 gate; license allowlist + advisories + bans + sources all green). +- `cargo doc --no-deps --all-features` builds without warnings (ADR-023 gate). +- **GitHub Actions CI green** on the WP1 PR across all five jobs (fmt, clippy, nextest, doc, deny) (ADR-023 gate). - `clarion install && clarion analyze .` in a fresh tempdir produces the expected `skipped_no_plugins` run row with zero entities. - L1 (full schema migration `0001`), L2 (`entity_id()` implementation), L3 (WriterCmd + per-N-batch writer-actor) are each covered by at least one test. - ADR-005 is Accepted and linked from the ADR index. +- ADR-023 is Accepted and linked from the ADR index (authored pre-Task-1; exit gate is that every Task-1 artefact listed in ADR-023's Decision section is present). - Every UQ-WP1-* is marked resolved with the chosen outcome recorded as a comment in the code, an ADR amendment, or an update to this doc's §5. diff --git a/docs/implementation/sprint-1/wp2-plugin-host.md b/docs/implementation/sprint-1/wp2-plugin-host.md index 34e9e55b..adbb7230 100644 --- a/docs/implementation/sprint-1/wp2-plugin-host.md +++ b/docs/implementation/sprint-1/wp2-plugin-host.md @@ -95,26 +95,77 @@ parser + validator; WP3 ships the first real manifest. ```toml [plugin] -name = "clarion-plugin-python" # unique plugin id +name = "clarion-plugin-python" # package name; informational (hyphens OK, human-readable) +plugin_id = "python" # identifier fed to entity_id(); must match [a-z][a-z0-9_]* (ADR-022) version = "0.1.0" # semver protocol_version = "1.0" # matches ADR-002 version executable = "clarion-plugin-python" # command on PATH (see L9) -language = "python" # informational; plugin_id in L2 EntityId comes from [plugin].name +language = "python" # informational tag extensions = ["py"] # file extensions this plugin claims -[capabilities] -max_rss_mb = 512 # prlimit on spawn (L6) -max_runtime_seconds = 300 # per-run wall clock -max_content_length_bytes = 10485760 # per-frame ceiling (L6); can be below the core's ceiling -max_entities_per_run = 100000 # per-run entity count cap (L6) +[capabilities.runtime] +# Declarations, not enforcements. Per ADR-021 §Layer 1, these values describe +# the plugin's expected envelope; the core uses them for sanity-warnings and +# to pick a floor no stricter than the plugin requested. The four Layer-2 +# minimums (Content-Length ceiling, entity-count cap, RSS limit, path jail) +# are core-enforced with fixed defaults a plugin cannot raise — see §L6. +expected_max_rss_mb = 512 # plugin's own RSS estimate; effective prlimit = min(this, core default 2 GiB) +expected_entities_per_file = 5000 # triggers CLA-INFRA-PLUGIN-ENTITY-OVERRUN-WARNING well before the 500k hard cap (warning impl deferred — see Task 4) +wardline_aware = true # plugin reads wardline.core.registry.REGISTRY (WP3 L8) +reads_outside_project_root = false # opt-out declaration; v0.1 refuses `true` at initialize (see Task 1 + Task 6) [ontology] entity_kinds = ["function", "class", "module", "decorator"] edge_kinds = ["imports", "calls", "decorates", "contains"] -rule_id_prefix = "CLA-PY-" # every emitted rule-ID must start with this +rule_id_prefix = "CLA-PY-" # every emitted rule-ID must start with this; must match ADR-022 grammar (see Task 1) ontology_version = "0.1.0" # bump when entity/edge/rule set changes ``` +**Reserved edge kinds**. Plugins may list the four core-reserved edge kinds +(`contains`, `guides`, `emits_finding`, `in_subsystem`) in `edge_kinds` to +signal they emit them, binding themselves to the core's semantics per +ADR-022. The semantics of those four kinds are fixed across all plugins; +plugins may not redefine them. Sprint 1's manifest schema carries no +per-edge-kind semantic annotations, so this compliance is automatic — +a manifest listing `contains` in `edge_kinds` parses successfully (Task 1 +test). + +**Rule-ID namespace**. Per ADR-022, `CLA-INFRA-*` is core-only (pipeline +findings), `CLA-FACT-*` is shared (any plugin or the core), and +`CLA-{PLUGIN_ID_UPPERCASE}-*` is reserved to that plugin. A manifest +declaring `rule_id_prefix = "CLA-INFRA-"` or `"CLA-FACT-"` is rejected at +parse (Task 1); emission-time enforcement of off-namespace rule IDs +(`CLA-INFRA-RULE-ID-NAMESPACE`) is deferred to the findings-emitting Tier B +sprint — Sprint 1's walking skeleton emits no findings, so the RPC-time +check has nothing to fire against. + +**Manifest-validation finding codes surfaced by this WP**: + +- `CLA-INFRA-MANIFEST-MALFORMED` — kind strings or `rule_id_prefix` fail + ADR-022's identifier grammar (`[a-z][a-z0-9_]*` for kinds, + `CLA-[A-Z]+(-[A-Z0-9]+)+` for rule-ID prefixes). Rejected at `initialize`; + plugin fails to start. +- `CLA-INFRA-MANIFEST-RESERVED-KIND` — manifest declares `file`, `subsystem`, + or `guidance` in `entity_kinds`. Rejected at `initialize`. +- `CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY` — manifest declares + `reads_outside_project_root = true`; v0.1 has no mechanism to allow it. + Rejected at `initialize`. +- `CLA-INFRA-RULE-ID-NAMESPACE` — manifest declares a reserved + `rule_id_prefix` (`CLA-INFRA-` or `CLA-FACT-`). Rejected at parse. + *Emission-time* enforcement (plugin emits a rule ID outside its namespace) + is deferred to the findings-emitting Tier B sprint. +- `CLA-INFRA-PLUGIN-ENTITY-OVERRUN-WARNING` — per-file entity count exceeds + `expected_entities_per_file`. Implementation deferred to the + catalog-emitting Tier B sprint (Sprint 1 is one file per invocation; the + warning has no useful surface area yet). + +**Shape note — TOML vs YAML**. WP2 specifies the manifest in TOML to match +the `plugins.toml` convention used elsewhere in detailed-design §1; +ADR-021 §Layer 1 and detailed-design §1 show the same data in YAML. The +field names, types, and semantics are identical — the on-disk encoding is +a WP2 local decision, called out here so a future reader comparing docs +doesn't treat the TOML shape as drift. + **Why now**: this schema is the core/plugin ontology boundary. Once plugins author manifests against it, schema changes become breaking. @@ -240,17 +291,34 @@ New workspace dependencies introduced by WP2: ## 5. Unresolved questions -- **UQ-WP2-01** — **Plugin discovery convention (L9)**: proposal is PATH + manifest - beside binary; see §2. **Resolution by**: Task 5. -- **UQ-WP2-02** — **JSON-RPC library choice**: hand-rolled over `serde_json` vs - `jsonrpsee` (async, batteries-included) vs `jsonrpc-core` (mature but older). - Hand-rolled wins on dep-surface; `jsonrpsee` wins on feature set (batching, - bidirectional notifications). Walking skeleton uses unidirectional unary → hand-roll - is enough. **Proposal**: hand-roll. **Resolution by**: Task 2. -- **UQ-WP2-03** — **Path jail semantics**: does canonicalisation follow symlinks? If - yes, a symlink pointing outside the analysis root is rejected. If no, a symlink - *within* the root that resolves outside is silently admitted. **Proposal**: yes, - follow symlinks; reject-on-escape. **Resolution by**: Task 4. +- **UQ-WP2-01** — **Plugin discovery convention (L9)**: ~~open~~ — + **resolved as PATH + neighbouring `plugin.toml`** per the §2 L9 proposal. + `discovery::discover_plugins` scans `$PATH` entries for + `clarion-plugin-*` basenames and loads `plugin.toml` from next to the + binary (install-prefix fallback deferred — WP3's `pip install -e` places + both binary and manifest in the same venv `bin/` directory, so the + fallback path has no Sprint-1 surface). World-writable `$PATH` entries + are refused at discovery time (`DiscoveryError::WorldWritableDir`; + scrub commit `7c0e396`). **Resolved**: Task 5 / `plugin::discovery`. +- **UQ-WP2-02** — **JSON-RPC library choice**: ~~open~~ — + **resolved as hand-rolled over `serde_json`** per the §UQ-WP2-02 + proposal. `transport.rs` owns Content-Length framing + frame read/write; + `protocol.rs` owns typed request/response structs for the L4 method set. + No `jsonrpsee` / `jsonrpc-core` dep added. **Resolved**: Task 2 / + `plugin::{transport, protocol}`. +- **UQ-WP2-03** — **Path jail semantics**: ~~open~~ — + **resolved as symlink-following `canonicalize` + `starts_with`** per the + §UQ-WP2-03 proposal. `jail::jail` canonicalises both root and candidate + via `std::fs::canonicalize` (which follows symlinks) and rejects any + candidate whose canonical form escapes the canonical root with + `JailError::EscapedRoot`. A symlink *within* the root that resolves + outside is rejected; a non-existent path is rejected. Caller-decides + drop-vs-kill policy (per ADR-021 §2a: drop-entity-not-plugin on first + offense; sub-breaker kills on >10/60s). **Note**: this is canonicalize-time + only — TOCTOU window for any downstream code that re-opens the path is + tracked in `clarion-928349b60f` and becomes critical when WP6 briefing + code reads `AcceptedEntity.source_file_path`. **Resolved**: Task 4 / + `plugin::jail`. - **UQ-WP2-04** — **Content-Length ceiling default**: ~~open~~ — **resolved by ADR-021 §2b**. Default ceiling is **8 MiB** per frame, floor 1 MiB, config key `clarion.yaml:plugin_limits.max_frame_bytes` @@ -266,46 +334,58 @@ New workspace dependencies introduced by WP2: On exceed: current in-flight batch flushed, plugin killed, run enters partial-results state, `CLA-INFRA-PLUGIN-ENTITY-CAP` emitted. **Resolved**: Task 4. -- **UQ-WP2-06** — **prlimit on non-Linux**: ADR-021 §2d names both paths - (`prlimit(RLIMIT_AS)` on Linux, `setrlimit(RLIMIT_AS)` on macOS — both - POSIX). Sprint 1 scope is **Linux-only** per - [WP1 §1 "Explicitly out of scope"](./wp1-scaffold.md#1-scope-sprint-1-narrow), - so the macOS path described in ADR-021 is out of scope *for Sprint 1 - implementation* even though it's in scope *for the ADR*. Do we - `#[cfg(target_os = "linux")]` the enforcement or compile an error? - **Proposal**: `#[cfg]`-gate the Linux implementation; on non-Linux, log - a warning once and proceed without the limit (the ADR-021 §2d macOS - path lands when Sprint N adds macOS support). **Resolution by**: Task 4. -- **UQ-WP2-07** — **Shape of plugin non-entity output**: does the plugin write progress - updates to stderr (free-form, the host just tees it to `tracing::info!`) or via JSON-RPC - notifications (`$/progress`)? Walking skeleton doesn't need progress, but the - convention is a lock-in-by-omission if not decided. **Proposal**: stderr is - free-form and forwarded to tracing; progress notifications are deferred. Plugins - that need structured progress add it in a later sprint. **Resolution by**: Task 3. -- **UQ-WP2-08** — **Plugin stdout discipline**: plugins must use stdout for JSON-RPC - only. Stray `print()` statements in a Python plugin will corrupt framing. How do - we enforce? **Proposal**: document in the WP3 plugin-author guide; the Python - plugin bootstraps by replacing `sys.stdout` with a non-writable wrapper during - initialisation. Not a core enforcement; plugin-level discipline. **Resolution by**: - Task 3 (documented in plugin-author docs). -- **UQ-WP2-09** — **Manifest hot-reload**: should the host re-read the manifest on - each analyze run, or cache it across runs within one `serve` session? Sprint 1 only - has `analyze`, so always-reload is simplest. **Proposal**: always-reload in Sprint 1; - revisit at WP8. **Resolution by**: Task 2. +- **UQ-WP2-06** — **prlimit on non-Linux**: ~~open~~ — + **resolved as `#[cfg(target_os = "linux")]`-gated enforcement**. + `limits::apply_prlimit_as` (plus `apply_prlimit_nofile_nproc` from + scrub commit `bd92600`) are Linux-only; non-Linux targets log once at + spawn and proceed without the RSS / fd / proc limits. macOS + `setrlimit` path per ADR-021 §2d lands when a future sprint adds macOS + support (Sprint 1 scope is Linux-only per WP1 §1). **Resolved**: + Task 4 / `plugin::limits`. +- **UQ-WP2-07** — **Shape of plugin non-entity output**: ~~open~~ — + **resolved as captured stderr ring buffer (diverges from original + proposal)**. Plugin stderr is piped (not inherited) into a bounded + 64 KiB ring buffer on `PluginHost`, accessible via + `host.stderr_tail() -> Option` for diagnostics (scrub commit + `b3c91a7`). This is a narrower surface than the original "tee to + `tracing::info!`" proposal — the scrub narrowed it because an + unbounded tee lets a chatty plugin flood core-side log drains. + Structured progress notifications (`$/progress`) are deferred to a + later sprint as originally planned. **Resolved**: Task 3 + scrub / + `plugin::host` (`stderr_tail`). +- **UQ-WP2-08** — **Plugin stdout discipline**: ~~open~~ — + **resolved as plugin-level discipline, not core enforcement**. WP2 + does not intercept plugin stdout; WP3's Python plugin will bootstrap + by replacing `sys.stdout` with a non-writable wrapper during + initialisation (stray `print()` would otherwise corrupt framing). The + host is resilient to unexpected frames via the drain-until-match loop + (`MAX_DRAIN_FRAMES = 16`, scrub commit `769a177`), so stdout + violations surface as framing / drain-budget errors rather than + silent corruption. **Resolved**: Task 3 (WP2 side — no core + enforcement); deferred to WP3 for the Python plugin's stdout swap. +- **UQ-WP2-09** — **Manifest hot-reload**: ~~open~~ — + **resolved as always-reload in Sprint 1**. `discovery::discover_plugins` + is invoked once per `clarion analyze` run and re-reads every + `plugin.toml` from disk; no in-memory manifest cache is carried across + runs. Manifest cache-across-`serve`-sessions is a WP8 concern. **Resolved**: + Task 2 / Task 5 (`plugin::{manifest, discovery}`). - **UQ-WP2-10** — **Crash-loop breaker parameters**: ~~open~~ — **resolved by ADR-002 + ADR-021 §Layer 3**. General breaker: **>3 crashes in 60s** → plugin disabled, `CLA-INFRA-PLUGIN-DISABLED-CRASH-LOOP`. Path-escape sub-breaker (ADR-021 §2a): **>10 escapes in 60s** → plugin killed, `CLA-INFRA-PLUGIN-DISABLED-PATH-ESCAPE`. Sprint 1 hard-codes both thresholds; config surface deferred to WP6. **Resolved**: Task 7. -- **UQ-WP2-11** — **What happens if the plugin returns an `id` that doesn't - match the 3-segment L2 format?** **Proposal**: host validates by - reconstructing the `EntityId` from the entity's `plugin_id` (known — the - emitting plugin), `kind`, and `qualified_name` fields and comparing against - the returned `id`; mismatch = drop entity + emit - `CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH`. This is the ontology-boundary - enforcement (ADR-022) extended to the identity format (ADR-003). - **Resolution by**: Task 6. +- **UQ-WP2-11** — **Plugin-returned `id` vs 3-segment L2 format**: ~~open~~ — + **resolved by identity check in `plugin::host` (T4)** per the §UQ-WP2-11 + proposal. On every `analyze_file` response, the host reconstructs the + expected `EntityId` from `(plugin_id, kind, qualified_name)` and compares + against the returned `id`; on mismatch the entity is dropped and + `CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH` is emitted. This extends ADR-022 + ontology-boundary enforcement to the ADR-003 identity format. The + host-level T4 test plus `cross_plugin_plugin_id_spoof_is_rejected` + (scrub commit `89b2da0`) cover both the shape-mismatch and cross-plugin + spoof attack surfaces. **Resolved**: Task 6 + scrub / `plugin::host` + (T4, `cross_plugin_plugin_id_spoof_is_rejected`). ## 6. Task ledger @@ -318,17 +398,23 @@ New workspace dependencies introduced by WP2: Steps: -- [ ] Define `Manifest`, `Capabilities`, `Ontology` structs mirroring the L5 schema. Use `serde` derive. +- [ ] Define `Manifest`, `Capabilities`, `CapabilitiesRuntime`, `Ontology` structs mirroring the L5 schema. Use `serde` derive. `Capabilities.runtime` is the ADR-021 §Layer 1 sub-struct carrying `expected_max_rss_mb`, `expected_entities_per_file`, `wardline_aware`, `reads_outside_project_root`. - [ ] Write failing tests: - - Positive: parse a valid `plugin.toml` fixture and assert all fields populated. + - Positive: parse a valid `plugin.toml` fixture and assert all fields populated, including `capabilities.runtime.*`. + - Positive (F5 / ADR-022 §Core owns): manifest listing a core-reserved edge kind (`contains`) in `edge_kinds` parses successfully — plugins bind to core semantics by listing the kind, they do not redefine it. - Negative: missing `[plugin].name` returns a clear error. - - Negative: `max_rss_mb = 0` rejected (must be > 0). + - Negative: `expected_max_rss_mb = 0` rejected (must be > 0). - Negative: `entity_kinds = []` rejected (must declare at least one). - - Negative: `rule_id_prefix` not ending in `-` rejected (L5 convention: prefixes end with `-`). + - Negative (ADR-022 identifier grammar): an entity kind not matching `[a-z][a-z0-9_]*` (e.g. `Function`, `func-tion`, `1function`) is rejected with `CLA-INFRA-MANIFEST-MALFORMED`. + - Negative (ADR-022 identifier grammar): a `rule_id_prefix` not matching `CLA-[A-Z]+(-[A-Z0-9]+)+` (e.g. `PY-`, `cla-py-`, `CLA-py-`) is rejected with `CLA-INFRA-MANIFEST-MALFORMED`. + - Negative (ADR-022 reserved kinds): a manifest declaring `file`, `subsystem`, or `guidance` in `entity_kinds` is rejected with `CLA-INFRA-MANIFEST-RESERVED-KIND`. + - Negative (ADR-022 namespace registry): `rule_id_prefix = "CLA-INFRA-"` rejected with `CLA-INFRA-RULE-ID-NAMESPACE` (core-only namespace). + - Negative (ADR-022 namespace registry): `rule_id_prefix = "CLA-FACT-"` rejected with `CLA-INFRA-RULE-ID-NAMESPACE` (shared namespace; plugins must use their own). + - Negative (ADR-021 §Layer 1): a manifest declaring `reads_outside_project_root = true` produces a validator result that the supervisor (Task 6) surfaces at `initialize` as `CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY`. Task 1's test asserts the validator flags the manifest; Task 6's test asserts the handshake rejection path. - [ ] Run tests; expect failures. -- [ ] Implement `pub fn parse_manifest(bytes: &[u8]) -> Result`. +- [ ] Implement `pub fn parse_manifest(bytes: &[u8]) -> Result`. Include the two ADR-022 grammar regexes, the reserved-entity-kind list (`file`, `subsystem`, `guidance`), and the reserved-prefix list (`CLA-INFRA-`, `CLA-FACT-`). - [ ] Run tests; expect pass. -- [ ] Commit: `feat(wp2): L5 plugin manifest parser and validator`. +- [ ] Commit: `feat(wp2): L5 plugin manifest parser and validator (ADR-021 §Layer 1 + ADR-022 grammar)`. ### Task 2 — JSON-RPC transport (L4) @@ -373,7 +459,8 @@ Steps: - `ContentLengthCeiling` with **8 MiB default** per ADR-021 §2b, consulted by transport.rs (refactor transport.rs to take a `&ContentLengthCeiling` in Task 2's ceiling test). - `EntityCountCap` with **500,000 default** per ADR-021 §2c; `try_admit(delta: usize) -> Result<(), CapExceeded>` tracks cumulative `entity + edge + finding` across the run. - `PathEscapeBreaker` with ADR-021 §2a threshold (**>10 escapes in 60s**) — rolling counter consumed by Task 6's host when a `JailError::EscapedRoot` is observed on a plugin response. - - `apply_prlimit_as(max_rss_mib: u64)` using `nix::sys::resource::setrlimit` inside `CommandExt::pre_exec` (pre-exec fork path) — applies `RLIMIT_AS` per ADR-021 §2d with **2 GiB default**. Effective limit = `min(manifest.capabilities.max_rss_mb, core_default)`. `#[cfg(target_os = "linux")]`-gated (UQ-WP2-06); on non-Linux, log-once warning. + - `apply_prlimit_as(max_rss_mib: u64)` using `nix::sys::resource::setrlimit` inside `CommandExt::pre_exec` (pre-exec fork path) — applies `RLIMIT_AS` per ADR-021 §2d with **2 GiB default**. Effective limit = `min(manifest.capabilities.runtime.expected_max_rss_mb, core_default)`. `#[cfg(target_os = "linux")]`-gated (UQ-WP2-06); on non-Linux, log-once warning. + - **Deferred**: `CLA-INFRA-PLUGIN-ENTITY-OVERRUN-WARNING` (ADR-021 §Consequences/Negative) — the per-file sanity warning fired when a plugin exceeds its declared `capabilities.runtime.expected_entities_per_file`. Sprint 1's walking skeleton is one file per invocation, so the warning has no useful surface area; implementation lands in the catalog-emitting Tier B sprint alongside multi-file discovery. Documented here so the subcode and declaration field stay wired end-to-end. - [ ] Tests for each; commit. - [ ] Commit: `feat(wp2): L6 core-enforced minimums — path jail, ceilings, prlimit (ADR-021 defaults)`. @@ -397,14 +484,16 @@ Steps: Steps: - [ ] Failing integration test: using a real subprocess (a tiny Rust binary in `tests/fixtures/` that speaks the protocol), `PluginHost::spawn(manifest, root)` completes a handshake, issues one `analyze_file` for a fixture, receives entities, and shuts down cleanly. Assert plugin exit code 0. +- [ ] Failing test (ADR-021 §Layer 1): a plugin whose manifest declares `capabilities.runtime.reads_outside_project_root = true` is refused at `initialize`; the host emits `CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY` and the plugin process is terminated before any `analyze_file` is issued. v0.1 has no mechanism to allow this capability. - [ ] Failing test: ontology-boundary enforcement (ADR-022) — the fixture plugin emits an entity with `kind: "unknown"` not in the manifest; host drops it and emits `CLA-INFRA-PLUGIN-UNDECLARED-KIND`. - [ ] Failing test: identity-mismatch rejection (UQ-WP2-11) — fixture plugin emits an entity whose `id` doesn't match `entity_id(plugin_id, kind, qualified_name)`; host drops it. - [ ] Failing test: path-jail drop-not-kill (ADR-021 §2a) — fixture plugin emits an `analyze_file` response with a `source.file_path` that canonicalises outside `project_root`. Host drops the entity, emits `CLA-INFRA-PLUGIN-PATH-ESCAPE`, and the plugin remains alive for the next request. - [ ] Failing test: path-escape sub-breaker (ADR-021 §2a) — fixture plugin emits 11 escaping paths within 60s; on the 11th, the host kills the plugin and emits `CLA-INFRA-PLUGIN-DISABLED-PATH-ESCAPE`. - [ ] Implement `host.rs`: - Spawn subprocess with `std::process::Command`, stdin/stdout piped. - - Apply `apply_prlimit_as` (from Task 4) inside `CommandExt::pre_exec` before `exec`, using `min(manifest.capabilities.max_rss_mb, core_default = 2 GiB)`. + - Apply `apply_prlimit_as` (from Task 4) inside `CommandExt::pre_exec` before `exec`, using `min(manifest.capabilities.runtime.expected_max_rss_mb, core_default = 2 GiB)`. - Perform handshake: send `initialize`, await response; send `initialized` notification. + - **Before** sending `initialized`: if `manifest.capabilities.runtime.reads_outside_project_root == true`, emit `CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY`, send `shutdown` + `exit`, and return a host error to the caller. Do not dispatch any `analyze_file` requests. (ADR-021 §Layer 1: v0.1 has no mechanism to allow this capability.) - Provide `PluginHost::analyze_file(path: &Path) -> Result>` that: - Runs the request-side path through the jail (jail error on request = host error returned to caller; no plugin involvement). - Sends request, awaits response. diff --git a/docs/implementation/sprint-1/wp3-python-plugin.md b/docs/implementation/sprint-1/wp3-python-plugin.md index ea6e078f..a7055346 100644 --- a/docs/implementation/sprint-1/wp3-python-plugin.md +++ b/docs/implementation/sprint-1/wp3-python-plugin.md @@ -2,7 +2,7 @@ **Status**: DRAFT — blocked-by WP2 **Anchoring design**: [detailed-design.md §1 (Plugin implementation — Python specifics)](../../clarion/v0.1/detailed-design.md#1-plugin-implementation-detail), [system-design.md §2](../../clarion/v0.1/system-design.md#2-core--plugin-architecture) -**Accepted ADRs**: [ADR-018](../../clarion/adr/ADR-018-identity-reconciliation.md), [ADR-022](../../clarion/adr/ADR-022-core-plugin-ontology.md) +**Accepted ADRs**: [ADR-018](../../clarion/adr/ADR-018-identity-reconciliation.md), [ADR-022](../../clarion/adr/ADR-022-core-plugin-ontology.md), [ADR-023](../../clarion/adr/ADR-023-tooling-baseline.md) **Predecessor**: [WP2](./wp2-plugin-host.md). **Blocks**: the Sprint 1 walking-skeleton demo. @@ -111,28 +111,45 @@ a Sprint 2 WP9 test against real Wardline annotations, or a manual spot-check du this WP). Response is documented in ADR-018 — the translator route — not a WP3 change. +**Divergence found at Sprint 1 close (2026-04-28)**: Wardline's +`FingerprintEntry` (`wardline/src/wardline/manifest/models.py:86-97`) +stores `(module: str, qualified_name: str)` as **separate fields** — +`module` is the source file path (e.g. `demo.py`) and `qualified_name` +is Python's bare `__qualname__` (e.g. `Foo.bar`). Clarion's L7 emits a +single combined `{dotted_module}.{__qualname__}` string. The two +encodings carry the same information but are not byte-equal — joining +requires a translator that composes +`f"{module_dotted_name(wardline.module)}.{wardline.qualified_name}"` on +the Wardline side using Clarion's `module_dotted_name` rules. Sprint 1 +does not exercise the join (the L8 probe verifies presence + version +only), so no Sprint-1 code path is broken. Tracked in +**`clarion-889200006a`** for ADR-018 amendment when WP9 attempts the +first real join. + ### L8 — Wardline `REGISTRY` import + version-pin protocol **What locks**: the import path (`from wardline.core.registry import REGISTRY`) and the version-pin syntax used in the plugin's `plugin.toml` (or a dedicated `wardline_compat` field). -**Symbol verification** (2026-04-18, pre-sprint check): both symbols exist in -the Wardline source at this sprint's start and can be relied on: +**Symbol verification** (re-checked at Sprint 1 close 2026-04-28): both symbols +remain present in the Wardline source and the in-range probe returns +`enabled` against `pip install -e /home/john/wardline`: - `wardline.core.registry.REGISTRY` — declared at `wardline/src/wardline/core/registry.py:55` as a `MappingProxyType[str, RegistryEntry]`. - `wardline.__version__` — re-exported from `wardline/src/wardline/__init__.py:3` - (sourced from `wardline._version`). + (sourced from `wardline._version`); current value `1.0.0`. -Both are usable today; UQ-WP3-03 resolves to "fully wire" (no stub-only -fallback). +UQ-WP3-03 resolves to "fully wire" (no stub-only fallback). **Sprint 1 pin approach**: -- Manifest field: `[integrations.wardline]` section with `min_version = "0.1.0"` and - `max_version = "0.2.0"` (semver range). Exact numbers set when Wardline's current - version is checked. +- Manifest field: `[integrations.wardline]` section with `min_version = "1.0.0"` + and `max_version = "2.0.0"` (semver half-open range). Updated from the + pre-sprint placeholder `0.1.0`/`0.2.0` to admit the actual current + Wardline 1.x; 2.0.0 is exclusive so a future major bump triggers an + explicit re-pin rather than silent drift. - Plugin startup probe: 1. Attempt `import wardline.core.registry`. If `ImportError`, record `wardline absent` in the handshake response's `capabilities` field and proceed. @@ -167,12 +184,14 @@ exercised lock-in is the honest one. ``` /plugins/python/ - pyproject.toml # package metadata, entry-point: clarion-plugin-python + pyproject.toml # package metadata, entry-point, [tool.ruff], [tool.mypy], [tool.pytest] plugin.toml # L5 manifest + .pre-commit-config.yaml # ADR-023: ruff-check, ruff-format, mypy hooks README.md # install + dev notes src/ clarion_plugin_python/ __init__.py + py.typed # PEP 561 marker so downstream mypy picks up stubs __main__.py # entry point; runs the JSON-RPC server loop server.py # JSON-RPC framing + dispatch extractor.py # ast visitor producing entities (L7) @@ -205,25 +224,36 @@ not core-vendored code" per ADR-022. Minimal. `pyproject.toml` declares: -- `python_requires = ">=3.11"` (UQ-WP3-04 — proposal; revisit Task 1). +- `python_requires = ">=3.11"` (UQ-WP3-04 — resolved: 3.11). - No runtime deps beyond the standard library for Sprint 1. `ast`, `json`, `sys`, - `os`, `pathlib` are all stdlib. -- Dev deps: `pytest`, `pytest-cov`, `ruff`, `mypy`. + `os`, `pathlib` are all stdlib. Task 6 adds `packaging` for Wardline version + comparisons. +- Dev deps (per ADR-023 tooling baseline): `pytest`, `pytest-cov`, `ruff` + (lint + format; strict config), **`mypy`** (`--strict` from day 1), and + `pre-commit` (hooks for ruff-check, ruff-format, mypy). All wired into CI + via a separate GitHub Actions job that installs the plugin editable and + runs `ruff check`, `ruff format --check`, `mypy --strict`, and `pytest`. - Optional dep: `wardline` (declared in `[project.optional-dependencies] integrations`). The plugin works without Wardline; declaring it optional allows `pip install clarion-plugin-python[integrations]` to pull Wardline when desired. ## 5. Unresolved questions -- **UQ-WP3-01** — **Qualname for nested class methods**: `class A: class B: def c():`. - Python's `__qualname__` gives `A.B.c`. Confirm the L7 rule matches this without - edge cases. **Proposal**: yes, follow `__qualname__` exactly; add the case as a - test fixture. **Resolution by**: Task 3. -- **UQ-WP3-02** — **How does the plugin handle syntax errors in the source file?** - `ast.parse()` raises `SyntaxError`. Options: (a) skip the file + log + emit zero - entities for that file; (b) fail the run. **Proposal**: (a) — skip + log. Unusable - files should not abort analysis; WP4 may later attach a finding. **Resolution - by**: Task 4. +- **UQ-WP3-01** — **Qualname for nested class methods**: ~~open~~ — + **resolved as "follow ``__qualname__`` exactly"**. `class A: class B: def c():` + produces `A.B.c` (class parents chain with `.`, no `` marker). + `qualname.reconstruct_qualname` tests cover this directly + (`test_nested_class_method_chains_class_names`) plus the harder + class-in-function-in-class case (`Foo.bar..Local.meth`) where + `` appears once, only at the function-parent boundary. + **Resolved**: Task 3 / `plugin.qualname`. +- **UQ-WP3-02** — **Syntax-error handling**: ~~open~~ — **resolved as + "skip + stderr log"** per the original proposal. `extract()` catches + `SyntaxError` from `ast.parse`, writes one line to `sys.stderr` + (`clarion-plugin-python: skipping : syntax error at line N: `), + and returns `[]`. The run continues; WP4 may later attach a finding. + `test_syntax_error_yields_empty_list_and_logs_to_stderr` is the + discriminating test. **Resolved**: Task 4 / `plugin.extractor`. - **UQ-WP3-03** — **Fully wire Wardline import in Sprint 1 or stub?** ~~open~~ — **resolved as "fully wire"**. Pre-sprint symbol check (see L8) confirmed `wardline.core.registry.REGISTRY` and `wardline.__version__` exist in the @@ -235,64 +265,108 @@ Minimal. `pyproject.toml` declares: for `ast.unparse` availability and better error messages; 3.12 raises the install barrier without a Sprint 1 payoff. Clarion users are developers with reasonable Python versions available. **Resolved**: Task 1. -- **UQ-WP3-05** — **Module-path normalisation**: the `module_path` entity - property and the derivation of the dotted-module prefix for L7's - `canonical_qualified_name` are both rooted at the analysis root (the arg - passed to `clarion analyze`). Does WP3 receive the root explicitly in - `analyze_file` params, or is each path already root-relative from the - host? **Proposal**: the host passes root-relative paths after jail - normalisation (WP2 L6); WP3 does not re-canonicalise. Cross-check with WP2 - Task 6 implementation. **Resolution by**: Task 4. -- **UQ-WP3-06** — **Handling of `__init__.py` module-path**: should the module-path - for entities in `pkg/__init__.py` be `pkg/__init__.py` or `pkg`? Proposal: - `pkg/__init__.py` (the literal file path); `pkg` is semantic module naming that - WP4 can synthesise if needed. Simplicity wins: file path is unambiguous. - **Resolution by**: Task 4. -- **UQ-WP3-07** — **Type-annotation functions (`typing.overload`, protocol methods)**: - do they get emitted like regular functions? **Proposal**: yes — they're still - `def`-bound names; WP4 can later add a `CLA-PY-OVERLOAD` rule if useful. - **Resolution by**: Task 3. -- **UQ-WP3-08** — **Byte-for-byte `EntityId` parity strategy**: how do we - maintain parity with WP1's Rust implementation? Option (a): both - implementations read the same spec (ADR-003) and rely on tests. Option (b): - ship a shared test fixture file (JSON) with input triples + expected - outputs; both implementations' test suites consume it. **Proposal**: (b) — - a shared `fixtures/entity_id.json` file at the repo root. Each row contains - `{plugin_id, kind, canonical_qualified_name, expected_entity_id}`. Exact - same inputs, exact same expected outputs; divergence fails CI on both - sides. **Resolution by**: Task 5. -- **UQ-WP3-09** — **Plugin logging destination**: stderr for free-form (per WP2 - UQ-WP2-07 resolution) or a file under `.clarion/logs/`? **Proposal**: stderr; - core forwards to tracing; `.clarion/logs/` is a Sprint 2+ decision. **Resolution - by**: Task 2. -- **UQ-WP3-10** — **Testing infrastructure**: **Resolved — pytest + ruff**. - Mypy adoption deferred until the plugin grows enough to benefit. - **Resolved**: Task 1. -- **UQ-WP3-11** — **What does the plugin return for an empty `.py` file (zero - functions)?** An empty `entities` array. Confirm WP2's host handles this without - tripping any alert. **Resolution by**: Task 4. -- **UQ-WP3-12** — **How does the plugin identify itself in the `initialize` - handshake?** Proposal: return `{"name": "clarion-plugin-python", "version": "0.1.0", - "ontology_version": "0.1.0"}` matching the manifest. Host cross-checks against - the manifest and fails handshake if mismatched. **Resolution by**: Task 2. +- **UQ-WP3-05** — **Module-path normalisation**: ~~open~~ — **resolved + as plugin-side relativisation** (diverges from original proposal). The + host sends absolute paths (WP2's CLI canonicalises `project_root` + and walks via `entry.path()` — see + `crates/clarion-cli/src/analyze.rs`), so the plugin captures + `project_root` from the `initialize` handshake and relativises + incoming `file_path` values against it when deriving the dotted-module + prefix for `qualified_name`. `source.file_path` emitted on the wire + stays absolute so the host's path jail canonicalise-and-compare works. + `extract(source, file_path, *, module_prefix_path=...)` decouples the + two paths. **Resolved**: Task 7 / `plugin.server._resolve_module_path`. +- **UQ-WP3-06** — **`__init__.py` handling**: ~~open~~ — **resolved as + "collapse to package name for dotted prefix; keep literal file_path"**. + `pkg/__init__.py` produces `module_dotted_name == "pkg"` (not + `pkg.__init__`), so entities emit `qualified_name = "pkg.package_helper"`. + `source.file_path` stays as the literal `pkg/__init__.py` — the file + is unambiguous even when the module name collapses. `test_init_py_ + collapsed_to_package_name` is the discriminating test. **Resolved**: + Task 4 / `plugin.extractor.module_dotted_name`. +- **UQ-WP3-07** — **`typing.overload` / protocol methods**: ~~open~~ — + **resolved as "regular function entities"**. Overloaded methods are + `FunctionDef`s with a decorator list — the extractor emits each one + as a separate entity with the same `qualified_name`, matching Python's + own `__qualname__` behaviour. A future `CLA-PY-OVERLOAD` rule can add + semantic annotation in a later sprint. `test_overloaded_method_gets_ + regular_qualname` covers three overloads + the implementation. + **Resolved**: Task 3 / `plugin.qualname`. +- **UQ-WP3-08** — **Byte-for-byte `EntityId` parity strategy**: ~~open~~ — + **resolved as "shared JSON fixture file"**. `fixtures/entity_id.json` + at the repo root has 20 rows covering module-level functions, class + methods, ``-marked nested functions, core file/subsystem + entities, and hypothetical go/rust plugin IDs. Both + `crates/clarion-core/src/entity_id.rs::tests::shared_fixture_byte_for_byte_parity` + and `plugins/python/tests/test_entity_id.py::test_matches_shared_fixture` + consume the same file and assert byte-equal output; divergence fails + CI on both sides in lockstep. **Resolved**: Task 5 / `fixtures/entity_id.json`. +- **UQ-WP3-09** — **Plugin logging destination**: ~~open~~ — **resolved + as "stderr for diagnostics"**. `extractor` writes syntax-error and + read-error messages to `sys.stderr` via `sys.stderr.write`. The host + captures stderr into a bounded 64 KiB ring buffer (WP2 scrub commit + `b3c91a7`, resolving UQ-WP2-07); diagnostics are surfaced via + `host.stderr_tail()`. `.clarion/logs/` as a persistent log destination + is a Sprint 2+ decision. **Resolved**: Task 2 + Task 4 / `plugin.server`, + `plugin.extractor`. +- **UQ-WP3-10** — **Testing + tooling infrastructure**: ~~"pytest + ruff; + mypy adoption deferred until the plugin grows enough to benefit."~~ — + **reopened 2026-04-18 and re-resolved by + [ADR-023](../../clarion/adr/ADR-023-tooling-baseline.md)**. The deferred + framing was the canonical tell for unexamined tech debt: every Python + module written without mypy would be a module to retrofit later. ADR-023 + adopts `pytest`, `ruff` (strict `select = ["ALL"]` config minus pragmatic + excludes), **`mypy --strict` from day 1**, and **`pre-commit`** wiring + ruff-check + ruff-format + mypy into every `git commit`. CI runs the same + four gates as a separate job. **Resolved**: Task 1. +- **UQ-WP3-11** — **Empty `.py` file response**: ~~open~~ — **resolved + as "empty entities array"**. `extract("", ...)` returns `[]`. The host + accepts an empty array without tripping any cap or alert. + `test_empty_file_yields_zero_entities` and + `test_whitespace_only_file_yields_zero_entities` cover the edge cases. + **Resolved**: Task 4 / `plugin.extractor`. +- **UQ-WP3-12** — **`initialize` response identity**: ~~open~~ — + **resolved as "match the manifest exactly"**. The handshake returns + `{name: "clarion-plugin-python", version: "0.1.0", ontology_version: + "0.1.0", capabilities: {...}}` — every field populated from the + package `__version__` + the `ONTOLOGY_VERSION` module constant in + `plugin.server`. Cross-check against manifest happens on the host side + (WP2 scrub commit `1ac32b1` validates `ontology_version` non-empty). + `test_initialize_roundtrip` is the discriminating test. **Resolved**: + Task 2 / `plugin.server.handle_initialize`. ## 6. Task ledger -### Task 1 — Python package skeleton +### Task 1 — Python package skeleton + ADR-023 tooling baseline **Files**: -- Create `/plugins/python/pyproject.toml` +- Create `/plugins/python/pyproject.toml` (package metadata + `[tool.ruff]` strict config + `[tool.mypy]` `strict = true` + `[tool.pytest.ini_options]`) +- Create `/plugins/python/.pre-commit-config.yaml` (ruff-check, ruff-format, mypy hooks) - Create `/plugins/python/src/clarion_plugin_python/__init__.py` +- Create `/plugins/python/src/clarion_plugin_python/py.typed` (PEP 561 marker) - Create `/plugins/python/src/clarion_plugin_python/__main__.py` - Create `/plugins/python/README.md` - Create `/plugins/python/tests/__init__.py` +- Extend `/.github/workflows/ci.yml` with a `python-plugin` job running ruff + mypy + pytest Steps: -- [ ] Write `pyproject.toml` with `project.name = "clarion-plugin-python"`, `requires-python = ">=3.11"` (UQ-WP3-04), `project.scripts.clarion-plugin-python = "clarion_plugin_python.__main__:main"`, no runtime deps, dev deps `pytest` + `ruff`. -- [ ] Write `__main__.py` with a `main()` that prints `clarion-plugin-python 0.1.0\n` to stderr and exits 0 (so `pip install -e .` produces a verifiable binary). -- [ ] `pip install -e plugins/python` and verify `which clarion-plugin-python` returns a path and running it exits 0. -- [ ] Commit: `feat(wp3): Python plugin package skeleton`. +- [ ] Write `pyproject.toml` with `project.name = "clarion-plugin-python"`, `requires-python = ">=3.11"` (UQ-WP3-04), `project.scripts.clarion-plugin-python = "clarion_plugin_python.__main__:main"`, no runtime deps, dev deps `pytest`, `pytest-cov`, `ruff`, `mypy`, `pre-commit` (ADR-023). +- [ ] Configure `[tool.ruff]` with `target-version = "py311"`, `line-length = 100`, `select = ["ALL"]`, pragmatic excludes per ADR-023 (`D` docstring lints relaxed; `COM812`/`ISC001` to avoid format conflict; per-file-ignores for `tests/` and fixtures). `[tool.ruff.format]` matches defaults. +- [ ] Configure `[tool.mypy]` with `strict = true`, `python_version = "3.11"`, `warn_unused_configs = true`. Add `[[tool.mypy.overrides]]` entries for any third-party modules without stubs (Sprint 1: none yet; Task 6 may add `packaging` once it's pulled in). +- [ ] Configure `[tool.pytest.ini_options]` with `testpaths = ["tests"]`, `addopts = "--strict-markers --cov=clarion_plugin_python --cov-report=term-missing"`. +- [ ] Write `.pre-commit-config.yaml` with hooks for `ruff check --fix`, `ruff format`, and `mypy` (using `additional_dependencies` to install stubs mypy needs inside the hook env). +- [ ] Write `py.typed` as an empty file — PEP 561 marker making the package's own type hints visible to downstream mypy consumers. +- [ ] Write `__main__.py` with a typed `def main() -> int:` that writes `clarion-plugin-python 0.1.0\n` to `sys.stderr` and returns 0 (so `pip install -e .` produces a verifiable binary with full type coverage). +- [ ] `pip install -e plugins/python[dev]` (dev extras) and verify locally: + - `which clarion-plugin-python` returns a path and running it exits 0. + - `ruff check plugins/python` passes. + - `ruff format --check plugins/python` passes. + - `mypy --strict plugins/python` passes (Sprint 1's tiny surface makes this trivial; the discipline is set for every subsequent task). + - `pytest plugins/python` passes (no tests yet — an empty test discovery returning "no tests ran" is the expected Task-1 shape). +- [ ] `pre-commit install` and `pre-commit run --all-files` passes. +- [ ] Extend `.github/workflows/ci.yml` with a `python-plugin` job that installs Python 3.11, runs `pip install -e plugins/python[dev]`, and executes the same four gates (`ruff check`, `ruff format --check`, `mypy --strict`, `pytest`). +- [ ] Commit: `feat(wp3): Python plugin package skeleton + ADR-023 tooling baseline`. ### Task 2 — JSON-RPC server loop + stdout discipline @@ -380,7 +454,7 @@ Steps: Steps: -- [ ] Write `plugin.toml` matching WP2 L5 schema: `[plugin]` (name, version, protocol_version, executable, `language = "python"`, `extensions = ["py"]`), `[capabilities]` (RSS 512MB, 300s runtime, 10MB frame ceiling, 100k entity cap), `[ontology]` (kinds = `["function"]`, edge_kinds = `[]`, `rule_id_prefix = "CLA-PY-"`, `ontology_version = "0.1.0"`), `[integrations.wardline]` (`min_version = "0.1.0"`, `max_version = "0.2.0"`). +- [ ] Write `plugin.toml` matching WP2 L5 schema: `[plugin]` (name, `plugin_id = "python"`, version, protocol_version, executable, `language = "python"`, `extensions = ["py"]`), `[capabilities.runtime]` per ADR-021 §Layer 1 (`expected_max_rss_mb = 512`, `expected_entities_per_file = 5000`, `wardline_aware = true`, `reads_outside_project_root = false`), `[ontology]` (kinds = `["function"]`, edge_kinds = `[]`, `rule_id_prefix = "CLA-PY-"`, `ontology_version = "0.1.0"`), `[integrations.wardline]` (`min_version = "0.1.0"`, `max_version = "0.2.0"`). The Wardline-specific values in `[integrations.wardline]` flow from the `wardline_aware = true` declaration. - [ ] Arrange installation to place `plugin.toml` where WP2's discovery (L9) finds it: at install-prefix `share/clarion/plugins/clarion-plugin-python/plugin.toml`. Using `tool.setuptools` or `hatch` data-file declarations in `pyproject.toml`. Verify after `pip install -e .` the file is discoverable. - [ ] Modify `analyze_file` handler: read the requested path, run `extractor.extract()`, return `{"entities": [...]}`. - [ ] Commit: `feat(wp3): plugin.toml manifest + analyze_file wired to extractor`. @@ -424,7 +498,12 @@ WP3 is done for Sprint 1 when all of: Rust (`clarion-core::entity_id`) and Python (`test_entity_id.py`) test suites. - Round-trip self-test passes. - Every UQ-WP3-* is marked resolved in §5. -- `pip install -e plugins/python` works on a clean Python 3.11 venv and +- `pip install -e plugins/python[dev]` works on a clean Python 3.11 venv and `clarion-plugin-python` is on `$PATH`. +- **ADR-023 gates green** (all four): `ruff check plugins/python`, + `ruff format --check plugins/python`, `mypy --strict plugins/python`, and + `pytest plugins/python` all pass on the WP3 closing commit. +- **`pre-commit run --all-files` passes** on the WP3 closing commit. +- **GitHub Actions `python-plugin` job green** on the WP3 PR. See also [`signoffs.md` Tier A](./signoffs.md#tier-a--sprint-1-close-walking-skeleton). diff --git a/docs/implementation/v0.1-plan.md b/docs/implementation/v0.1-plan.md index c1c07cb3..ac28e38d 100644 --- a/docs/implementation/v0.1-plan.md +++ b/docs/implementation/v0.1-plan.md @@ -158,6 +158,8 @@ Each WP follows the same shape: ### WP1 — Core scaffold and storage layer +> **Sprint 1 delivery (narrow walking-skeleton scope)** — closed 2026-04-18. The Sprint 1 slice covers L1 (SQLite schema), L2 (3-segment EntityId format), L3 (writer-actor command protocol) per [`sprint-1/wp1-scaffold.md`](./sprint-1/wp1-scaffold.md). Reader-pool stress timing, `--shadow-db` opt-in, and full pipeline-phase wiring listed below remain on the v0.1 backlog (Sprint 2+). [`sprint-1/signoffs.md §A.1`](./sprint-1/signoffs.md#a1-storage-layer-wp1) is the gate that closed. + **Anchoring docs**: `system-design.md` §4 (Storage), `detailed-design.md` §3 (Storage implementation), `system-design.md` §1 (process topology). @@ -219,6 +221,8 @@ implementation), `system-design.md` §1 (process topology). ### WP2 — Plugin protocol and hybrid authority +> **Sprint 1 delivery (narrow walking-skeleton scope)** — closed 2026-04-24. The Sprint 1 slice covers L4 (JSON-RPC method set + Content-Length framing), L5 (`plugin.toml` schema), L6 (the four ADR-021 core-enforced minimums), and L9 (plugin discovery convention) per [`sprint-1/wp2-plugin-host.md`](./sprint-1/wp2-plugin-host.md). Multi-plugin orchestration, streaming responses, dynamic plugin loading during `serve`, and seccomp/namespace sandboxing are NOT in Sprint 1 (deferred to later sprints or NG-09 for v0.1). [`sprint-1/signoffs.md §A.2`](./sprint-1/signoffs.md#a2-plugin-host-wp2) is the gate that closed. + **Anchoring docs**: `system-design.md` §2 (Core/Plugin Architecture), `detailed-design.md` §1 (Plugin implementation detail, language-agnostic portion). @@ -273,6 +277,8 @@ ontology ownership boundary). ### WP3 — Python plugin v0.1 +> **Sprint 1 delivery (narrow walking-skeleton scope)** — closed 2026-04-28. The Sprint 1 slice covers L7 (Python qualname production format) and L8 (Wardline REGISTRY probe + version-pin protocol) per [`sprint-1/wp3-python-plugin.md`](./sprint-1/wp3-python-plugin.md). Function entities only (module-level + class methods); classes / decorators / module entities, edge emission (`imports`, `calls`, `decorates`, `contains`), import resolution, and the full `CLA-PY-*` rule catalogue are deferred to the WP3-feature-complete sprint. [`sprint-1/signoffs.md §A.3`](./sprint-1/signoffs.md#a3-python-plugin-wp3) is the gate that closed. + **Anchoring docs**: `detailed-design.md` §1 (Python-specific subsections), `system-design.md` §2 (plugin contract from the plugin's side). diff --git a/docs/superpowers/handoffs/2026-04-18-wp2-plugin-host-handoff.md b/docs/superpowers/handoffs/2026-04-18-wp2-plugin-host-handoff.md new file mode 100644 index 00000000..177dbecc --- /dev/null +++ b/docs/superpowers/handoffs/2026-04-18-wp2-plugin-host-handoff.md @@ -0,0 +1,253 @@ +# Clarion Sprint 1 WP2 — Plugin host + hybrid authority (handoff prompt) + +This file is the starting prompt for a fresh Claude Code session that will +execute WP2. It is designed to be pasted directly as the user's first +message, or referenced by path. It is self-contained; the new session +should not need prior conversation context to pick up cleanly. + +--- + +# Continue Clarion Sprint 1 WP2 execution + +I'm handing off WP2 (plugin host + hybrid authority) after completing WP1 +(scaffold + storage). You are continuing the same sprint with the same +discipline. This prompt is the full handoff. + +## Working directory + branch + +- Directory: `/home/john/clarion` +- Branch: `sprint-1/wp2-plugin-host` (already checked out; off `main` tip `ad8d4ce` which is the WP1 merge commit) +- `main` has WP1 already merged via `--no-ff` so `git log --first-parent main` shows WP-level boundaries. +- Do NOT amend existing commits; stack new commits on top. + +## The project + +Clarion is a code-archaeology tool, one of four products in the Loom suite +(Clarion, Filigree, Wardline, proposed Shuttle). Read `CLAUDE.md` at repo +root for the doctrine, ADR precedence rules, and the Loom federation axiom. + +Sprint 1 ships the walking-skeleton across WP1 (scaffold + storage — DONE), +WP2 (plugin host — in progress), WP3 (Python plugin — blocked-by WP2). +You are executing WP2. + +## What WP1 delivered (already on `main`) + +- Cargo workspace (edition 2024, rustc 1.95) with three crates: `clarion-core`, `clarion-storage`, `clarion-cli`. +- Tooling floor per **ADR-023**: `rust-toolchain.toml`, `rustfmt.toml`, `clippy.toml` (pedantic), `deny.toml`, `.github/workflows/ci.yml`, workspace `[lints]`. +- `clarion-core`: `entity_id()` assembler (L2 3-segment format per ADR-003 + ADR-022), `EntityIdError`, `LlmProvider` trait + `NoopProvider` stub. +- `clarion-storage`: SQLite schema migration `0001_initial_schema.sql` (L1 per detailed-design §3 — tables, FTS5, triggers, generated columns, `guidance_sheets` view), `ReaderPool` (deadpool-sqlite), `Writer` actor (L3 per ADR-011 — `tokio::task::spawn_blocking`, bounded mpsc, per-N batch COMMIT, WriterCmd enum with oneshot acks, `WriterProtocol` error variant for contract violations). +- `clarion-cli`: `clarion install` (creates `.clarion/{clarion.db,config.json,.gitignore}` + `clarion.yaml`; ADR-005 governs the `.gitignore` contents), `clarion analyze` (Sprint 1 stub — BeginRun → CommitRun with status `skipped_no_plugins`; WP2 replaces this stub). +- 48 tests pass, all 7 ADR-023 gates green (build dev + release, fmt, clippy pedantic `-D warnings`, nextest, doc, deny). + +**Key exported API that WP2 will consume**: + +- `clarion_storage::{Writer, DEFAULT_BATCH_SIZE, DEFAULT_CHANNEL_CAPACITY, EntityRecord, RunStatus, WriterCmd}`. +- `Writer::send_wait(|ack| WriterCmd::...) -> Result` — canonical async helper for enqueuing commands. The WP1 review loop made this the preferred pattern over hand-rolling oneshot channels. +- `WriterCmd::InsertEntity { entity: Box, ack }` — note the `Box` on the entity; it is required by clippy's `large_enum_variant` and is part of the L3 locked-in shape. +- `clarion_core::{entity_id, EntityId, EntityIdError, LlmProvider, NoopProvider}`. +- `clarion_storage::StorageError` variants: `Sqlite(#[from] rusqlite::Error)`, `Pool*(#[from] deadpool)`, `PragmaInvariant(String)`, `Migration{version, source}`, `Io(#[from] io::Error)`, `WriterGone`, `WriterProtocol(String)`, `WriterNoResponse`. `StorageError` is `!Sync`; bridge to `anyhow` via `.map_err(|e| anyhow::anyhow!("{e}"))`. + +## The spec + +Canonical WP2 spec: `docs/implementation/sprint-1/wp2-plugin-host.md` + +Outline (9 tasks): + +1. Manifest parser (L5) — `plugin.toml` schema per ADR-022. +2. JSON-RPC transport (L4) — Content-Length framing + method set per ADR-002. +3. In-process mock plugin (test harness). +4. Core-enforced minimums (L6) — path jail drop-not-kill first offense + >10/60s sub-breaker, 8 MiB Content-Length ceiling, 500k per-run entity cap, 2 GiB `RLIMIT_AS` per ADR-021. +5. Plugin discovery (L9). +6. Plugin-host supervisor. +7. Crash-loop breaker. +8. Wire `clarion analyze` to use the plugin host (replaces the Sprint 1 stub). +9. WP2 end-to-end smoke test. + +Anchoring ADRs: **ADR-002** (plugin transport JSON-RPC over stdio), **ADR-021** (plugin authority hybrid — declared capabilities + core-enforced minimums), **ADR-022** (core/plugin ontology ownership boundary). + +Sign-off ladder: `docs/implementation/sprint-1/signoffs.md` Tier A.2. Tier A.1 is done; do NOT touch it. Your closure target is A.2.1 through A.2.9 with `locked on ` stamps for L4, L5, L6, L9. + +## Plan status — WRITE ONE FIRST + +Unlike WP1, there is **no plan file yet** under `docs/superpowers/plans/`. +Your first step is to write one using the `superpowers:writing-plans` +skill, using the WP2 spec (`docs/implementation/sprint-1/wp2-plugin-host.md`) +as input. Target path: +`docs/superpowers/plans/2026-04-18-wp2-plugin-host.md` (or whatever date +you write it on). + +The WP1 plan (`docs/superpowers/plans/2026-04-18-wp1-scaffold-storage.md`) +is a reference for the shape — 10 tasks with per-task files, full verbatim +code blocks, commit messages, and ADR-023 gate requirements inline. Match +that shape. + +After writing, optionally run `axiom-planning:plan-review` for a reality / +risk / complexity / convention-alignment check before execution. WP2's +surface is wider than WP1's (subprocess management, frame parsing, jail +enforcement) so a plan review is probably worth the tokens. + +Then execute the plan using `superpowers:subagent-driven-development` +exactly as WP1 did — fresh implementer subagent per task, two-stage review +(spec compliance first, then code quality) after each, chore(wp2) fix +commits stacked without amending. See the "Execution protocol" section +below. + +## Filigree state + +- **WP2 issue**: `clarion-9dee2d24c3`, status `defined`, `is_ready: true`. + Labels: `adr:002 adr:021 adr:022 release:v0.1 sprint:1 wp:2`. + Blocks: WP3 (`clarion-cd84959ee9`) and Sprint 1 close (`clarion-30ca615264`). +- Transition path at WP2 start: `defined` → `executing` via + `mcp__filigree__claim_issue` or `update_issue status=executing`. +- Transition at WP2 close: `executing` → `delivered` (same as WP1's path). + +- **WP1 issue** `clarion-2eadcfe651` is `delivered`. Do not touch it. + +- **Pending observation** `clarion-obs-67175f4486`: priority generated + column TEXT affinity breaks lexicographic ordering for numeric + priorities. Flagged during WP1 Task 3 review. It is a design-doc + question (detailed-design.md §3 §737), not a WP2 task. Leave it pending + for Sprint 1 close triage UNLESS WP2 introduces an `ORDER BY priority` + query that forces the decision earlier — in which case, raise with the + user before acting. + +## Accepted patterns from WP1 that carry forward to WP2 + +These were established during WP1's review loops and should be applied +from Task 1: + +1. **Cargo hygiene** + - `cargo nextest run` needs `--no-tests=pass` in any CI / script / doc command. + - Internal path deps pin a version: `clarion-core = { path = "...", version = "0.1.0-dev" }` to satisfy `cargo-deny`'s `wildcards = "deny"`. + - New workspace deps go in `[workspace.dependencies]` and are referenced via `dep.workspace = true` from crate manifests. + - `Cargo.lock` is committed. + - No `#[allow(...)]` for pedantic warnings — fix in-code. + +2. **Common pedantic resolutions used in WP1** + - `doc_markdown`: backtick identifiers like `SQLite`, `PRAGMA`, `JSON-RPC`, type names. + - `cast_possible_truncation`: `u32::try_from(...).expect("...")` with human-readable expect strings — never `as u32`. + - `missing_errors_doc`: `# Errors` section on every public fallible fn. + - `missing_panics_doc`: document or restructure. + - `needless_pass_by_value`: `&T`. + - `large_enum_variant`: `Box` the largest variant(s); check with a test-revert under clippy rather than taking anyone's word for whether it fires. + - Doc link resolution: fully qualify as `[crate::module::Item]` when short form doesn't resolve. + +3. **Error design** + - `#[from]` for structured error types; `String` wrappers only for semantic violations without a wrapped error (e.g. `WriterProtocol(String)` for contract violations that have no underlying library error). + - `StorageError` is `!Sync` (deadpool's `InteractError` panic payload). Bridging to `anyhow::Error` uses `.map_err(|e| anyhow::anyhow!("{e}"))`. WP2 may add a similar boundary for the plugin host's error type — if so, consider whether `!Sync` propagates. + +4. **State-machine correctness** + - Update state BEFORE the fallible op, not after. `?` short-circuits on Err + and skips the state update, desynchronising in-memory state from the + external system. Task 6 fixed this in the writer actor's COMMIT path; + watch for the same pattern in WP2's subprocess state machine (did you + mark the child "dead" before calling `wait()`? did you mark the jail + "tripped" before applying the kill?). + +5. **Review loop discipline** + - Every task: implementer subagent (sonnet, general-purpose) → spec + compliance reviewer (sonnet, general-purpose, verifies against + spec not against implementer's self-report) → if clean, code + quality reviewer (axiom-rust-engineering:rust-code-reviewer, + sonnet) → if issues, fix subagent → re-review. Never skip the + re-review. + - Spec reviewers MUST verify independently. Task 6 in WP1 caught an + implementer self-report error ("clippy didn't flag it") only because + the spec reviewer actually test-reverted the change and ran clippy. + - Fix commits are `chore(wp2):` commits stacked on the `feat(wp2):` + commit they address. Never amend. + +6. **Doc markdown for verbatim SQL / JSON-RPC**: use triple-backtick fenced + blocks in doc comments. If you put raw `{` / `}` in rustdoc, fully + escape them with backticks or wrap in `
`; otherwise rustdoc tries
+   to interpret them.
+
+7. **Gates run on every commit, not just task-end**: the 6 gates (build /
+   fmt / clippy / nextest / doc / deny) go green on every `feat(wp2):`
+   and every `chore(wp2):` commit. The release build gate runs at Task N
+   close (mirroring WP1 Task 9's pattern).
+
+## Lock-ins to land + reviewer hotspots
+
+- **L4 — JSON-RPC method set + Content-Length framing (ADR-002)**: `initialize`, `initialized`, `analyze_file`, `shutdown`, `exit`. Reviewer hotspots: Content-Length header parsing (malformed framing, oversized payloads per L6's 8 MiB ceiling), JSON-RPC error code fidelity, mid-message disconnect handling.
+- **L5 — `plugin.toml` schema (ADR-022)**: reviewer hotspots: missing-required-field errors, ontology declaration shape (`[ontology].entity_kinds` drives host-side filtering).
+- **L6 — core-enforced minimums (ADR-021)**: path jail (drop-not-kill on first escape; >10/60s → kill = sub-breaker), 8 MiB Content-Length ceiling, 500k per-run entity cap, 2 GiB `RLIMIT_AS`. Reviewer hotspots: each invariant needs a positive and a negative test; the sub-breaker's sliding-window logic is a timing hazard (flake-check 3x for any test that relies on wall-clock).
+- **L9 — plugin discovery**: finds `clarion-plugin-*` binaries on `$PATH`, loads neighbouring `plugin.toml`. Reviewer hotspots: cross-platform `$PATH` handling (WP2 scope is Linux but don't assume POSIX everywhere in code comments), missing-manifest errors, multiple-plugins-with-same-name behaviour.
+
+Additionally, WP2 Task 8 **wires `clarion analyze` to use the plugin host**. The current Sprint-1 stub ends with `RunStatus::SkippedNoPlugins`; after WP2 Task 8 it should use `RunStatus::Completed` when at least one plugin runs and produces output. Keep `SkippedNoPlugins` as a valid status for the no-plugins-installed case.
+
+## Execution protocol — FULL DISCIPLINE per task
+
+Invoke `superpowers:subagent-driven-development` at session start. Then
+for each task run the full review loop. Example dispatch shapes (copied
+from WP1 practice; adjust task specifics):
+
+- **Implementer subagent**: `general-purpose`, `sonnet`. Pass the task's
+  full code blocks from the plan (do not ask it to "read the plan" —
+  violates the skill). Include: working dir, branch, tip commit,
+  ADR-023 gate requirements, pre-answered FAQ (patterns above),
+  commit-message HEREDOC, self-review checklist, structured report
+  format (STATUS / COMMIT_SHA / TEST_COUNT / GATE_EXITS / DEVIATIONS /
+  CONCERNS).
+
+- **Spec compliance reviewer**: `general-purpose`, `sonnet`. Include: commit
+  under review, parent commit, explicit "do not trust the implementer's
+  report", required files list, required public API surface, required
+  test names, required behaviour, and a ledger of all 6 (or 7 at
+  task-9 close) gates to run independently. Report format with
+  per-surface ✅/❌ + VERDICT.
+
+- **Code quality reviewer**: `axiom-rust-engineering:rust-code-reviewer`,
+  `sonnet`. Include: scope (two commits: feat + any prior chore), project
+  context (ADR-023 floor, lock-in relevance), accepted deviations from
+  implementer's report, specific questions to answer (error handling,
+  state machine, shutdown discipline, jail soundness for L6, Send/Sync
+  correctness for subprocess handles), strengths / issues (Critical /
+  Important / Minor / Nitpick) / assessment format.
+
+- **Fix subagent (when needed)**: `general-purpose`, `sonnet`. Pass exact
+  fix instructions per reviewer finding. Stack a new `chore(wp2):`
+  commit — do NOT amend. Re-run all 6 gates. Flake-check concurrency /
+  timing tests 3 times. Report format same as implementer.
+
+- **Re-review**: dispatch the same code-quality reviewer on the fix
+  commit. Expect a short response (<400 words). Approve or request
+  further fixes.
+
+## Commit-message conventions (from WP1)
+
+- `feat(wp2): ` for new functionality.
+- `chore(wp2): apply Task N code-review fixes` for fix commits.
+- `test(wp2): ...` for test-only commits (e.g. the E2E test at Task 9 close).
+- `docs(sprint-1): ...` for spec / signoff / lock-in doc updates (Task 9 or the final sign-off task).
+- Each message body ends without a trailer unless the user asks for a `Co-Authored-By:` line.
+
+## Final-commit sanity checklist at WP2 close
+
+- All 7 ADR-023 gates exit 0 (build dev + release, fmt, clippy `-D warnings`, nextest, doc, deny).
+- Every Tier A.2 box ticked in `signoffs.md` with `locked on ` on L4, L5, L6, L9.
+- L4, L5, L6, L9 stamped in `docs/implementation/sprint-1/README.md` §4.
+- Every UQ-WP2-* in `wp2-plugin-host.md` §5 marked resolved with resolving task.
+- `git log --oneline main..` on the WP2 branch shows the expected commit sequence.
+- Filigree: `mcp__filigree__update_issue clarion-9dee2d24c3 status=delivered`. WP3 becomes ready.
+
+## Start here
+
+1. Run `mcp__filigree__get_issue clarion-9dee2d24c3` to see current WP2 state.
+2. Read `docs/implementation/sprint-1/wp2-plugin-host.md` end-to-end.
+3. Read `docs/clarion/adr/ADR-002-plugin-transport-json-rpc.md`, `ADR-021-plugin-authority-hybrid.md`, `ADR-022-core-plugin-ontology.md`.
+4. Optional: skim `docs/superpowers/plans/2026-04-18-wp1-scaffold-storage.md` to see the plan shape.
+5. Invoke `superpowers:writing-plans` and produce `docs/superpowers/plans/-wp2-plugin-host.md`.
+6. Optional: run `axiom-planning:plan-review` on the plan.
+7. Transition Filigree: `mcp__filigree__update_issue clarion-9dee2d24c3 status=executing`.
+8. Invoke `superpowers:subagent-driven-development`.
+9. Dispatch Task 1 (manifest parser, L5) implementer. Work through Tasks 2-9, closing with the E2E test + signoff updates.
+
+Good luck. WP1's 18-commit review-looped history is on `main`; your WP2
+history should follow the same shape.
+
+---
+
+*Handoff written by the previous session on 2026-04-18. Branch
+`sprint-1/wp2-plugin-host` created off the WP1 merge commit `ad8d4ce`.
+48 tests passing on the branch tip at handoff time.*
diff --git a/docs/superpowers/handoffs/2026-04-19-wp2-tasks-4-to-9-handoff.md b/docs/superpowers/handoffs/2026-04-19-wp2-tasks-4-to-9-handoff.md
new file mode 100644
index 00000000..0fea4cae
--- /dev/null
+++ b/docs/superpowers/handoffs/2026-04-19-wp2-tasks-4-to-9-handoff.md
@@ -0,0 +1,287 @@
+# Clarion Sprint 1 WP2 — Remaining Tasks 4–9 (handoff prompt)
+
+This file is the starting prompt for a fresh Claude Code session that will
+execute WP2 Tasks 4–9 (core-enforced minimums, plugin discovery, plugin-host
+supervisor, crash-loop breaker, analyze wiring, E2E smoke). Paste it as the
+user's first message. It is self-contained.
+
+Supersedes `2026-04-18-wp2-plugin-host-handoff.md` which covered all of WP2
+and was written before Tasks 1–3 landed.
+
+---
+
+# Continue Clarion Sprint 1 WP2 — Tasks 4 through 9
+
+You are picking up WP2 (plugin protocol + hybrid authority) after Tasks 1–3
+landed in the previous session. Tasks 4–9 remain. Continue the same
+discipline: subagent-driven-development, one commit per task, ADR-023 gates
+clean on every commit.
+
+## Working directory + branch
+
+- Directory: `/home/john/clarion`
+- Branch: `sprint-1/wp2-plugin-host`
+- Current HEAD: `bd56cd5` (pre-Task-4 hardening)
+- Merge base with `main`: `ad8d4ce` (WP1 merge commit)
+
+5 commits already on this branch, test suite at 74 passing, clippy + fmt
+clean at every commit.
+
+## What's already shipped (don't redo)
+
+| SHA | Summary | Scope |
+|---|---|---|
+| `c2cb07a` | Task 1 L5 manifest parser + validator | `plugin/manifest.rs`, `plugin/mod.rs`, `lib.rs`, adds `toml` workspace dep |
+| `4b20244` | Task 2 L4 Content-Length transport + typed protocol | `plugin/transport.rs`, `plugin/protocol.rs` |
+| `f3ca3ab` | Task 2 fix round (null-params → `{}`, double-payload rejection, EINTR retry, flush-on-write, 8 KiB header cap, trim fix) | same 2 files |
+| `b27594d` | Task 3 in-process mock plugin (`#[cfg(test)]`-gated) | `plugin/mock.rs`, `plugin/mod.rs` |
+| `bd56cd5` | Pre-Task-4 hardening: `plugin_id` split from `name`, PathBuf→String in protocol params, mock state guards, mock tick() inbox preservation | 5 files |
+
+## What remains (Tasks 4–9 per plan spec)
+
+Plan spec: `docs/implementation/sprint-1/wp2-plugin-host.md` §6 Task ledger.
+
+- **Task 4** — Core-enforced minimums (L6): `plugin/jail.rs`, `plugin/limits.rs`. Path jail, Content-Length ceiling (8 MiB), entity-count cap (500k), `apply_prlimit_as` (2 GiB RSS, Linux-only).
+- **Task 5** — Plugin discovery (L9): `plugin/discovery.rs`. PATH scan for `clarion-plugin-*` binaries + neighbouring `plugin.toml`.
+- **Task 6** — Plugin-host supervisor: `plugin/host.rs`. Spawn subprocess, handshake, request-response loop, jail enforcement on response paths, ontology boundary enforcement, identity-mismatch rejection, shutdown+exit on drop. **Largest task.**
+- **Task 7** — Crash-loop breaker: `plugin/breaker.rs`. Parameters per ADR-002 + ADR-021 §Layer 3 (>3 crashes/60s general; >10 path-escapes/60s path-escape sub-breaker).
+- **Task 8** — Wire `clarion analyze` to plugin host: modify `clarion-cli/src/analyze.rs`. Discover, spawn, iterate files by extension, persist entities via WP1's writer-actor.
+- **Task 9** — WP2 E2E smoke test: `clarion-cli/tests/wp2_e2e.rs`. `clarion install` + `clarion analyze fixture_dir/` produces a completed run with persisted mock-plugin entities.
+
+## Methodology
+
+Use the `superpowers:subagent-driven-development` skill. For each task:
+
+1. Dispatch an implementer subagent (general-purpose, sonnet) with the full
+   task text from the plan + context + gotchas (see per-task notes below).
+2. When implementer reports DONE, dispatch a spec-compliance reviewer
+   (general-purpose, sonnet). Verify each task bullet maps to a concrete
+   test function. Don't trust the implementer's report.
+3. When spec review passes, dispatch a code-quality reviewer
+   (`superpowers:code-reviewer`, sonnet). If issues are CRITICAL or
+   IMPORTANT, send the same implementer a fix round via `SendMessage`
+   to the agent's ID. Re-review after fixes.
+4. When quality review approves (even with follow-up observations), mark
+   the task complete and move on. File P3/P4 follow-ups as filigree
+   observations with `mcp__filigree__observe`.
+
+Each task ends with one commit. Commit message template:
+`feat(wp2): `. Fix rounds use `fix(wp2): ...`.
+
+## Doctrine (read before touching code)
+
+- **`docs/suite/loom.md`** — Loom federation axiom. Any "wouldn't it be
+  easier if we just added X" proposal must pass the §5 failure test.
+- **`docs/implementation/sprint-1/wp2-plugin-host.md`** — the plan you are
+  executing. §6 Task ledger is your scope boundary. §L5, L6, L9 are the
+  lock-ins.
+- **`docs/clarion/adr/ADR-002-plugin-transport-json-rpc.md`** — transport.
+- **`docs/clarion/adr/ADR-021-plugin-authority-hybrid.md`** — the four
+  core-enforced minimums. §Layer 1 is the manifest runtime sub-block
+  (already implemented in Task 1). §Layer 2 is what Task 4 implements.
+- **`docs/clarion/adr/ADR-022-core-plugin-ontology.md`** — identifier
+  grammar, reserved kinds, rule-ID namespace. Task 6's ontology-boundary
+  enforcement cites this.
+- **`docs/implementation/sprint-1/signoffs.md`** — Tier A A.2.1–A.2.12.
+  Every box should tick when WP2 is done. A.2.3 (L6 locked) and A.2.5–
+  A.2.7 (ontology enforcement, identity-mismatch, crash-loop) are the key
+  gates for Tasks 4–7. A.2.8 is Task 8's gate.
+
+## Build / test commands (ADR-023 gates — run on every commit)
+
+```bash
+cargo nextest run -p clarion-core                                       # unit tests
+cargo nextest run --workspace --all-features                            # full suite
+cargo fmt --all -- --check                                              # formatting
+cargo clippy --workspace --all-targets --all-features -- -D warnings    # lint (pedantic level warn)
+cargo deny check                                                        # advisories/licenses/bans
+cargo doc --no-deps --all-features                                      # doc build
+```
+
+Expected current state: 74 tests, all green, all gates clean.
+
+## Filigree backlog (read these BEFORE starting each task)
+
+13 tickets remain open. They split into "do now" vs "trigger when". Query
+the full list with `mcp__filigree__list_issues --status=open` or the CLI
+equivalent; titles here for orientation:
+
+**Task 6 triggers (act on when Task 6 lands):**
+- `clarion-e46503831c` P3 — `Manifest.integrations` leaks `toml::Value`. Revisit if Task 6 forwards integrations through the handshake. If not, defer.
+- `clarion-29acbcd042` P4 — crate-root re-exports too broad. Once `PluginHost` becomes the façade, prune `lib.rs` re-exports down to what external consumers (CLI crate) actually need.
+
+**Task 5 triggers:**
+- `clarion-fa35cad487` P4 — extension values not validated. When Task 5 or Task 8 implements extension-to-file-matching, decide the format grammar (`"py"` vs `".py"` vs `"*.py"`) and add parser validation.
+
+**Related observation (P3):**
+- `clarion-obs-cc9fe7d44c` — **important for Task 6**. The mock's `tick()` preserves the full failing frame on dispatch error so subsequent ticks re-attempt it. This works for the mock (caller controls lifecycle) but Task 6's real supervisor must not copy the pattern unchanged — a plugin that emits a permanently-bad frame would make the supervisor loop forever. Real supervisor should advance past unrecoverable frames and/or count per-frame failures.
+
+**Cleanup backlog (P3/P4, can be done anytime, not blockers):**
+- `clarion-078814da2d` P3 — rule-ID prefix doc comment `*` vs `+`
+- `clarion-ebd790422c` P4 — char-class duplicate in `entity_id.rs`
+- `clarion-c76cd2028e` P4 — manifest.rs test boilerplate (1188 lines, mostly tests)
+- `clarion-bfea7d248b` P4 — `id: i64` scope note
+- `clarion-6865a6607c` P4 — `#[non_exhaustive]` annotations on `TransportError`, `ResponsePayload`, `ManifestError`
+- `clarion-49803b9dd0` P4 — vacuous second assertion in mock crashing test
+- `clarion-f46ebccd5d` P4 — oversize mock state diagram note
+- `clarion-a2f7406889` P4 — mock `write_response` tmp Vec allocation
+- `clarion-80a48c51cb` P4 — `drive_to_ready` test helper
+
+## Per-task gotchas (read before dispatching each implementer)
+
+### Task 4 — Core-enforced minimums
+
+**Files**: create `plugin/jail.rs` and `plugin/limits.rs`.
+
+Key points from the plan:
+
+- `jail.rs` — `pub fn jail(root: &Path, candidate: &Path) -> Result`. Canonicalise both via `std::fs::canonicalize` (follows symlinks per UQ-WP2-03 resolution). Assert `canonical_candidate.starts_with(canonical_root)`. Typed `JailError::EscapedRoot { offending: PathBuf }` on violation.
+
+- **B2 integration** (from commit `bd56cd5`): protocol params are `String`, not `PathBuf`. Jail should return `Result` for wire-format use, or expose both `PathBuf` (internal) and `String` (wire) accessors. Add `JailError::NonUtf8Path` variant. Task 6 calls `jail()` on both request-side paths (before sending) and response-side paths (on each entity's `source.file_path`).
+
+- `limits.rs` — four types:
+  - `ContentLengthCeiling` with 8 MiB default (ADR-021 §2b). **Refactor** `transport.rs::read_frame` to take `&ContentLengthCeiling` instead of `max_bytes: usize`. Existing transport tests pass `usize`; update them.
+  - `EntityCountCap` with 500k default (ADR-021 §2c). `try_admit(delta: usize) -> Result<(), CapExceeded>` tracks cumulative `entity + edge + finding` counts across a run.
+  - `PathEscapeBreaker` — rolling counter, trips at >10 escapes in 60 seconds (ADR-021 §2a sub-breaker). Consumed by Task 6's host when `JailError::EscapedRoot` observed on a response.
+  - `apply_prlimit_as(max_rss_mib: u64)` using `nix::sys::resource::setrlimit` inside `CommandExt::pre_exec`. 2 GiB default (ADR-021 §2d). Effective limit = `min(manifest.capabilities.runtime.expected_max_rss_mb, core_default)`. **`#[cfg(target_os = "linux")]`-gate** per UQ-WP2-06; on non-Linux, log-once warning.
+
+- **New dep**: `nix = "0.28"` or `rustix` (pick one, prefer `nix` per plan §4). Add to workspace deps and clarion-core's `Cargo.toml`.
+
+- **Deferred subcode** (plan §L6 note): `CLA-INFRA-PLUGIN-ENTITY-OVERRUN-WARNING`. Sprint 1 is one-file-per-invocation, no useful surface. Document the deferral in limits.rs; do not implement.
+
+- **Finding subcodes** this task must emit:
+  - `CLA-INFRA-PLUGIN-PATH-ESCAPE` — first-offense path escape (drop entity, plugin stays alive)
+  - `CLA-INFRA-PLUGIN-DISABLED-PATH-ESCAPE` — sub-breaker tripped (kill plugin)
+  - `CLA-INFRA-PLUGIN-FRAME-OVERSIZE` — Content-Length ceiling exceeded
+  - `CLA-INFRA-PLUGIN-ENTITY-CAP` — entity-count cap exceeded
+  - `CLA-INFRA-PLUGIN-OOM-KILLED` — plugin killed by RLIMIT_AS (detected via `WIFSIGNALED && WTERMSIG == 9`)
+
+- **Tests**: each minimum needs ≥1 positive and ≥1 negative test per signoff A.2.3.
+
+### Task 5 — Plugin discovery
+
+**Files**: create `plugin/discovery.rs`.
+
+- UQ-WP2-01 resolution (plan §L9): **PATH-based scan** for executables matching `clarion-plugin-*`, plus manifest load from either (a) next to the binary or (b) `/share/clarion/plugins//plugin.toml`. Neighbor-to-binary first, then install-prefix.
+
+- **Extension-grammar trigger**: ticket `clarion-fa35cad487`. If this task touches extension string handling, decide the format now (likely lowercase, no dot, no wildcard) and add validation to `manifest.rs::parse_manifest`. Close or update the ticket.
+
+- Test: discovery finds a mock `clarion-plugin-*` binary on a test `$PATH` and loads its manifest from the expected location.
+
+### Task 6 — Plugin-host supervisor (largest task)
+
+**Files**: create `plugin/host.rs`.
+
+This is the integration layer — Tasks 1–5 are building blocks; Task 6 wires them. Read the plan's Task 6 bullets carefully.
+
+- **Integration test harness**: uses a real subprocess fixture (not the mock). Plan says "a tiny Rust binary in `tests/fixtures/` that speaks the protocol". Create a minimal fixture binary that handles `initialize` → response → `analyze_file` → one-entity response → `shutdown` → `exit`.
+
+- **Entity-ID assembly**: uses `manifest.plugin.plugin_id` (NOT `name`!) per commit `bd56cd5`. Call `entity_id(manifest.plugin.plugin_id.as_str(), kind, qualified_name)`.
+
+- **String paths**: all `project_root` / `file_path` flowing through protocol are `String`. Convert from `PathBuf` via the jail helper at boundaries (jail owns UTF-8 validation per ticket `clarion-77c6971e81`).
+
+- **Reads-outside-project-root refusal** (ADR-021 §Layer 1, already tested at parser level): before sending `initialized`, call `manifest.validate_for_v0_1()`. On `UnsupportedCapability`, emit `CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY`, send `shutdown` + `exit`, do NOT dispatch `analyze_file`. Signoff A.2.12.
+
+- **Ontology enforcement** (ADR-022, signoff A.2.5): drop entities whose `kind` is not in `manifest.ontology.entity_kinds`. Emit `CLA-INFRA-PLUGIN-UNDECLARED-KIND`.
+
+- **Identity-mismatch enforcement** (UQ-WP2-11, signoff A.2.6): reconstruct `EntityId` from `(plugin_id, kind, qualified_name)` and compare against the returned `id`. Mismatch → drop + `CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH`.
+
+- **Jail on response paths** (ADR-021 §2a): for each returned entity/edge/finding, jail each `source.file_path` and evidence anchor path. On `JailError::EscapedRoot`: drop record, emit `CLA-INFRA-PLUGIN-PATH-ESCAPE`, tick `PathEscapeBreaker`. If breaker trips: kill plugin, emit `CLA-INFRA-PLUGIN-DISABLED-PATH-ESCAPE`.
+
+- **Inbox drain strategy** (observation `clarion-obs-cc9fe7d44c`): do NOT copy the mock's `tick()` pattern of preserving the failing frame. Real supervisor must advance past unrecoverable failing frames or the plugin will stall. Pick: (a) advance past; (b) per-frame failure count with bail-out; (c) hybrid.
+
+- **Crate-root re-export prune** (ticket `clarion-29acbcd042`): once `PluginHost` is the façade, trim `lib.rs` re-exports down to `PluginHost`, `Manifest`, `ManifestError`, `parse_manifest`, maybe `JailError`, `LimitError`, `EntityIdError`. Leave implementation types (`Frame`, `RequestEnvelope`, `TransportError`, etc.) accessible via `clarion_core::plugin::transport::*` for tests but not at the crate root.
+
+- **Integrations forwarding** (ticket `clarion-e46503831c`): does the supervisor need to forward `manifest.integrations` to the plugin? Check WP3 Task 6 plan. If yes, decide the type (either document `toml::Value` exposure or switch to `BTreeMap>`). If no, defer the ticket.
+
+- Signoffs A.2.5, A.2.6, A.2.12 are all Task 6's gate.
+
+### Task 7 — Crash-loop breaker
+
+**Files**: create `plugin/breaker.rs`.
+
+- Parameters per UQ-WP2-10 resolution: **>3 crashes in 60s** → plugin disabled, `CLA-INFRA-PLUGIN-DISABLED-CRASH-LOOP`. Hard-coded for Sprint 1; config surface deferred to WP6.
+
+- Uses `MockPlugin::new_crashing()` — the state guards from commit `bd56cd5` ensure crash-loop tests don't silently re-initialise.
+
+- Test: using `MockPlugin::new_crashing()`, attempt spawn/run N times in a rolling window; on the Nth failure, breaker trips and refuses further spawn attempts for the cooldown.
+
+- Signoff A.2.7.
+
+### Task 8 — Wire `clarion analyze`
+
+**Files**: modify `clarion-cli/src/analyze.rs` (existing skeleton from WP1's Task 8 commit `10005e8`).
+
+- Discover plugins via Task 5's `discovery.rs`.
+- For each discovered plugin, spawn via Task 6's `PluginHost::spawn`.
+- Iterate source tree, call `analyze_file` per matching file (`manifest.plugin.extensions`).
+- Persist returned entities via WP1's writer-actor.
+- On plugin error or cap hit: mark run as failed with diagnostic.
+
+- Signoff A.2.8.
+
+### Task 9 — E2E smoke test
+
+**Files**: create `clarion-cli/tests/wp2_e2e.rs`.
+
+- Integration test using the fixture mock-plugin binary (from Task 6) and a test `$PATH`.
+- `clarion install` in a tempdir + `clarion analyze fixture_dir/` produces a completed `runs` row with the mock's expected entity persisted.
+
+- Signoff A.2.8 (same as Task 8; this is the E2E proof).
+
+## Exit criteria for WP2
+
+All of:
+
+1. Signoffs A.2.1 through A.2.12 passing. Check each box in `signoffs.md`.
+2. All 9 tasks committed (5 already + 4 more = 9, plus fix rounds as needed).
+3. `cargo nextest run --workspace --all-features` passes.
+4. `cargo fmt --all -- --check` clean.
+5. `cargo clippy --workspace --all-targets --all-features -- -D warnings` clean.
+6. `cargo deny check` clean.
+7. Every UQ-WP2-* marked resolved in `wp2-plugin-host.md` §5.
+8. No regressions in WP1 tests.
+
+When WP2 is done, the user can choose to either merge to main (via
+`finishing-a-development-branch`) or keep going to WP3 on a new branch.
+
+## Pitfalls (from the first session, don't repeat)
+
+1. **Unit structs serialize to `null`, not `{}`** — use `struct Foo {}` with braces for any wire-format empty-params type. Already burned us in Task 2 (commit `f3ca3ab` fix).
+
+2. **`serde(flatten)` on response payload loses error fields** — if you need mutual-exclusivity between two keys, write a custom `Deserialize`. Task 2's `ResponseEnvelope` now does this.
+
+3. **`write_frame` must flush** — without it, `BufWriter` silently deadlocks. Already fixed in Task 2.
+
+4. **Body-read loop needs `Interrupted` retry** — EINTR can fire from signal delivery on subprocess pipes. Already fixed in Task 2.
+
+5. **Header-line memory DoS** — `BufRead::read_line` is unbounded. Use `MAX_HEADER_LINE_BYTES = 8 * 1024` cap. Already fixed in Task 2.
+
+6. **Reserved prefix check must run AFTER grammar check** — `CLA-INFRA-` passes the grammar `CLA-[A-Z]+(-[A-Z0-9]+)+` and must be rejected as `ReservedPrefix`, not `Malformed`. Task 1 already does this right; mirror in any grammar work Task 4/5 add.
+
+7. **`plugin_id` ≠ `plugin.name`** — commit `bd56cd5` split these. Anywhere that needs to feed `entity_id()`, use `manifest.plugin.plugin_id`. `name` is informational / human-readable.
+
+8. **Don't trust subagent reports** — spec reviewers must verify by reading code, not summary. The quality reviewer found 2 Critical + 4 Important issues in Task 2 that the implementer's self-review had missed.
+
+## Current state verification
+
+Before dispatching Task 4's first subagent, confirm the starting state:
+
+```bash
+cd /home/john/clarion
+git log --oneline sprint-1/wp2-plugin-host -10
+# should show: bd56cd5, b27594d, f3ca3ab, 4b20244, c2cb07a, ad8d4ce, ...
+
+cargo nextest run -p clarion-core
+# should show: 74 tests, all pass
+
+cargo clippy --workspace --all-targets --all-features -- -D warnings
+cargo fmt --all -- --check
+# both clean
+```
+
+If any of these fail, STOP and report — don't start Task 4 on a broken base.
+
+---
+
+You have everything you need. Start by dispatching the Task 4 implementer.
diff --git a/docs/superpowers/handoffs/2026-04-23-wp2-full-scrub-handoff.md b/docs/superpowers/handoffs/2026-04-23-wp2-full-scrub-handoff.md
new file mode 100644
index 00000000..c2f541ab
--- /dev/null
+++ b/docs/superpowers/handoffs/2026-04-23-wp2-full-scrub-handoff.md
@@ -0,0 +1,322 @@
+# Clarion Sprint 1 WP2 — Full Scrub Before WP3 (handoff prompt)
+
+This file is the starting prompt for a fresh Claude Code session that will
+do a **second full scrub of WP2** before the project moves on to WP3
+(Python plugin). Paste it as the user's first message. It is self-contained.
+
+Supersedes the two prior WP2 handoffs (`2026-04-18-wp2-plugin-host-handoff.md`
+and `2026-04-19-wp2-tasks-4-to-9-handoff.md`), which covered initial
+implementation. WP2 is now code-complete; the question this session
+answers is *"is it actually ready to sign off A.2 and move to WP3?"*
+
+---
+
+# Continue Clarion Sprint 1 WP2 — full review + fix pass
+
+You are picking up WP2 (plugin protocol + hybrid authority) **after code
+is in place and two review passes have already landed fixes**. The sprint
+lead called the landing "very wobbly" and asked for another scrub before
+WP3 begins. Your job is to find what's still wrong, fix what you can
+fix cheaply, and file what's worth filing — then leave the user with a
+clean call on whether A.2 can tick.
+
+Do not assume the prior reviews were exhaustive. They were time-boxed and
+biased toward what the agents-of-the-moment happened to look for. A
+third perspective usually turns up real issues the first two missed.
+
+## Working directory + branch
+
+- Directory: `/home/john/clarion`
+- Branch: `sprint-1/wp2-plugin-host`
+- Current HEAD: `a1cc3be` (after the four P1 review-2 bugs landed)
+- Merge base with `main`: `ad8d4ce` (WP1 merge commit)
+
+21 commits already on this branch; 153 tests passing; every ADR-023 gate
+(clippy pedantic, fmt, nextest, doc, deny) green on every commit.
+
+## What's already shipped (don't redo)
+
+WP2 implementation (Tasks 1–9) is complete. The most recent fixes are
+the four P1 review-2 bugs closed on 2026-04-23:
+
+| SHA | Issue | What it fixed |
+|---|---|---|
+| `5c5c3ee` | clarion-b6d7e077fd | RawEntity string fields bounded to 4 KiB (host RAM DoS) |
+| `0fcc57f` | clarion-64b53d174e | Reap child on `PluginHost::spawn` handshake failure (no zombies) |
+| `37b56d9` | clarion-f56dc6ee43 | `clarion analyze` exits non-zero on FailRun |
+| `a1cc3be` | clarion-978c8d6f15 | CrashLoopBreaker wired into analyze.rs + one-plugin-crash no longer tanks run |
+
+The last one introduced a behavioural change worth flagging to reviewers:
+`RunOutcome::SoftFailed` (plugin crashed, other plugins' entities still
+commit) vs `RunOutcome::HardFailed` (writer-actor error, rollback). See
+`crates/clarion-cli/src/analyze.rs` §run-outcome. This split is new and
+warrants scrutiny — it widens the CommitRun/FailRun semantics and
+implicitly extends the L3 writer-actor contract.
+
+## Scope of this scrub
+
+WP2 covers:
+
+- **L4** — JSON-RPC transport (Content-Length framing, typed protocol),
+  `crates/clarion-core/src/plugin/transport.rs` + `protocol.rs`
+- **L5** — `plugin.toml` manifest parser/validator per ADR-022,
+  `crates/clarion-core/src/plugin/manifest.rs`
+- **L6** — Core-enforced minimums per ADR-021 §Layer 2 (path jail,
+  Content-Length ceiling, entity cap, `RLIMIT_AS`),
+  `jail.rs` + `limits.rs`
+- **L9** — Plugin discovery convention (PATH + neighbour plugin.toml),
+  `discovery.rs`
+- **Host supervisor** — spawn, handshake, analyze_file pipeline,
+  ontology + identity enforcement, path-escape breaker, `host.rs`
+- **Crash-loop breaker** — ADR-002 + UQ-WP2-10 (>3 crashes/60s),
+  `breaker.rs`
+- **Analyze CLI wiring** — discover → walk → per-plugin spawn →
+  writer-actor, `crates/clarion-cli/src/analyze.rs`
+
+Anchoring documents to read-with:
+- `docs/implementation/sprint-1/wp2-plugin-host.md` — the WP doc
+- `docs/implementation/sprint-1/signoffs.md` §A.2 — the gate
+- `docs/clarion/adr/ADR-002-crash-loop-breaker.md`
+- `docs/clarion/adr/ADR-021-plugin-authority-hybrid.md`
+- `docs/clarion/adr/ADR-022-core-plugin-ontology.md`
+- `docs/clarion/adr/ADR-023-tooling-baseline.md`
+- `docs/clarion/v0.1/requirements.md` — REQ / NFR / CON IDs WP2 addresses
+- `docs/clarion/v0.1/system-design.md` §§4–6 (host, plugin protocol, limits)
+- `docs/clarion/v0.1/detailed-design.md` §§4–5 (wire schemas, rule catalogues)
+
+## Open WP2 review-2 tail at session start
+
+14 issues labelled `wp:2` still open — pull the current list yourself
+with `filigree list --label=wp:2 --status=open`. Summary at handoff
+time:
+
+- **P1** — `clarion-9dee2d24c3` the WP2 work-package issue itself
+  (closes when A.2 signoffs tick)
+- **P2** — `clarion-a287217267` RawEntity.extra / RawSource.extra Maps
+  still unbounded (properties_json DoS). Twin of the closed
+  b6d7e077fd — the suggested fix in its body is a bound on the total
+  serialised size of `extra` per entity.
+- **P3 (12 issues)** — mix of design gaps, missing tests, and polish:
+  - breaker surface gaps: JailError::Io/NonUtf8Path don't tick the
+    PathEscapeBreaker; `ContentLengthCeiling::unbounded()` is `pub`
+  - TOCTOU / shadow risk: `jail()` returns canonicalised path at one
+    moment; discovery dedupes by canonical but stores raw
+  - unbounded deserialisation: `ProtocolError.message/.data`;
+    manifest reads have no size cap; `integrations` table keys
+  - missing double-shutdown guard on `PluginHost::shutdown()`
+  - missing tests: analyze_file JSON-RPC error response; no
+    `initialized` notification after capability refusal; path-escape
+    count pinning
+  - poisoned-inbox drain/discard strategy
+- **P4** — `clarion-c850c27f33` `entities.len() as u64` unchecked cast
+
+These are a starting map, not a ceiling. Expect to find more.
+
+## A.2 signoff status
+
+`docs/implementation/sprint-1/signoffs.md` §A.2 has 12 ticks (A.2.1
+through A.2.12). None are currently ticked. Part of this session's job
+is to audit the signoffs ladder against the code and mark which are
+ready to lock. *Don't* tick them yourself without the user's explicit
+go-ahead — the `locked on ` stamp is a sprint-level
+commitment and takes a human in the loop.
+
+## Methodology
+
+Run in three phases. Commit-and-close as you go; don't batch.
+
+### Phase 1 — Independent reviewer sweep (parallel)
+
+Dispatch these reviewers in a **single message** (parallel) — they see
+the same code but from different angles, and finding overlap is signal.
+
+1. **`axiom-rust-engineering:rust-code-reviewer`** on the four WP2
+   source modules: `plugin/host.rs`, `plugin/transport.rs`,
+   `plugin/jail.rs`, `plugin/limits.rs`. Also `crates/clarion-cli/src/analyze.rs`.
+   Ask specifically about: error handling integrity, API surface, async
+   correctness, lifetime soundness. *Do not* ask for style nits —
+   clippy pedantic already caught those.
+
+2. **`ordis-security-architect:threat-analyst`** on the host ↔ plugin
+   trust boundary. Scope: what can a malicious/buggy plugin do to the
+   host that isn't already mitigated by the four P1 fixes? The prior
+   review caught the RawEntity RAM DoS; this one should look for the
+   next layer (deserialisation bombs in `extra` maps, symlink races in
+   discovery, pipe-write-flood causing host read-buffer growth, etc.).
+
+3. **`axiom-rust-engineering:unsafe-auditor`** on the single `unsafe`
+   block in `host.rs::spawn` (the `pre_exec` closure calling
+   `apply_prlimit_as`). Verify the SAFETY comment is accurate,
+   async-signal-safety holds, and no allocation or Rust drop runs
+   across the fork/exec boundary.
+
+4. **`ordis-quality-engineering:test-suite-reviewer`** on
+   `crates/clarion-core/src/plugin/*/tests` and
+   `crates/clarion-core/tests/host_subprocess.rs` +
+   `crates/clarion-cli/tests/wp2_e2e.rs` + `.../analyze.rs`. Look for:
+   sleepy assertions, test interdependence, brittle ordering, tests
+   that prove the mock rather than the code under test.
+
+5. **`ordis-quality-engineering:coverage-gap-analyst`** on the WP2
+   modules. Map test-to-source; identify untested error paths. Compare
+   against the signoff ladder — every A.2.x item should correspond to
+   at least one concrete test.
+
+6. **`axiom-sdlc-engineering:bug-triage-specialist`** — read the 14
+   open WP2 issues, cluster by root cause, identify any that are
+   duplicates of each other or symptoms of a common design gap. The
+   goal is to *not* play whack-a-mole — if 3 P3 findings all trace to
+   "deserialisation has no size limits anywhere," that's one fix with
+   three test cases, not three fixes.
+
+Give each reviewer the commit range `ad8d4ce..a1cc3be` as its scope.
+They should report in `CONFIDENT / PLAUSIBLE / SPECULATIVE` severity
+buckets per the SME Agent Protocol. Ignore SPECULATIVE unless it
+clusters with another reviewer's CONFIDENT.
+
+### Phase 2 — Synthesise and prioritise
+
+After all six reviewers return, you (the main agent) synthesise:
+
+- Which findings do multiple reviewers raise? (high-confidence real
+  issues)
+- Which findings are already filed? (look up by description match)
+- Which are duplicates of the P3 tail above?
+- What's the *new* surface — findings nobody filed yet?
+
+Produce a short triage doc (`docs/superpowers/handoffs/2026-04-23-wp2-scrub-findings.md`).
+Columns: finding, source (reviewer name), severity, existing issue ID
+or "new", proposed disposition (fix / file / defer / dismiss). Keep it
+to one page.
+
+### Phase 3 — Fix the right subset; file the rest
+
+For each "fix" item in the triage doc:
+
+1. Claim the issue in filigree (`filigree update  --status=fixing`
+   with severity/root_cause as required by the workflow).
+2. Write a failing test first (TDD discipline — the existing T-series
+   is your template).
+3. Implement the fix.
+4. Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`,
+   `cargo fmt --all -- --check`, `cargo nextest run --workspace --all-features`,
+   `cargo doc --no-deps --all-features`, `cargo deny check` — **every
+   gate must be green before commit**, per ADR-023.
+5. One commit per issue. Commit message cites the filigree ID.
+6. Close the filigree issue with the commit SHA in `fix_verification`.
+
+For each "file" item: create a new filigree issue with
+`--label=sprint:1 --label=wp:2` and a clear suggested-fix section.
+
+For each "defer" item: leave a note in the triage doc explaining why
+Sprint 1 doesn't need it. The user reads this to gut-check the
+deferrals.
+
+**Do not batch commits.** The previous sessions used
+subagent-driven-development with one-commit-per-task — keep the same
+discipline. A scrub fix that touches three modules and closes two
+issues becomes two commits, one per issue.
+
+## Specific areas where I'd look hard
+
+Not a requirements list — pointers based on what I know is thin:
+
+- **Deserialisation size limits.** The RawEntity fix covered four
+  named fields, but serde_json reads the whole frame body into a
+  `Value` tree first (`host.rs::analyze_file`, `entities_raw` line
+  512). A 6 MiB frame that's 100% nested objects still gets parsed
+  before per-field bounds fire. Is that OK? What's the actual memory
+  cost of serde_json parsing a 6 MiB pathological payload?
+
+- **Discovery dedup + symlink semantics.** `discover()` finds
+  `clarion-plugin-*` binaries on PATH. If two PATH entries resolve to
+  the same canonical file, what happens? If a symlink changes between
+  discovery and spawn, what happens? If a PATH entry is a world-
+  writable directory, can an attacker inject a `clarion-plugin-*` and
+  have it picked up?
+
+- **Writer-actor contract.** The Reading-A′ change uses
+  `CommitRun(Failed)` where FailRun was the documented path for
+  "something went wrong." Is `CommitRun(Failed)` actually guaranteed
+  to commit the open entity transaction? (It works in the test, but
+  I want the writer author to confirm the contract.) Does the WP1
+  writer-actor doc need updating to reflect this usage?
+
+- **Shutdown timing.** `PluginHost::shutdown()` sends `shutdown` +
+  `exit` then returns. The CLI's `run_plugin_blocking` calls
+  `child.wait()` afterward. If the plugin doesn't actually exit
+  (hangs on a bad signal handler), `wait()` blocks forever. There's
+  no timeout. Sprint 1 might accept that; I'd at least note it.
+
+- **JSON-RPC error response on analyze_file.** Issue `clarion-e190f1e72b`
+  flags there's no test for this. What actually happens in the code?
+  Trace `host.rs::analyze_file` when the response payload is
+  `ResponsePayload::Error` — the code converts it to
+  `HostError::Protocol(e)`, which the CLI classifies as "transport/
+  protocol error" and treats as a plugin crash. That's correct but
+  untested.
+
+- **ADR-021 §Layer 3 coverage.** The path-escape sub-breaker exists,
+  the crash-loop breaker exists. What about the ADR-021 §Layer 3
+  *general* crash-escalation language? Read ADR-021 §3 and check if
+  anything is declared but unimplemented.
+
+## Exit criteria
+
+The user wants to know: "is WP2 ready to ship and does A.2 tick?"
+
+To answer that, by end of session you should have:
+
+1. A findings triage doc in `docs/superpowers/handoffs/` with every
+   new reviewer finding categorised.
+2. All "fix" items landed as individual commits with the ADR-023 gates
+   green. Expect somewhere between 3 and 10 new commits, depending on
+   what the reviewers surface.
+3. All "file" items in filigree.
+4. A short summary message to the user:
+   - What you found beyond the existing P2/P3 tail
+   - What you fixed this session
+   - What you filed but didn't fix (and why)
+   - Your call on A.2: "ready to tick" vs "still has N blockers, specifically X, Y, Z"
+   - Your call on whether WP3 can start now or should wait
+
+Do **not** tick A.2 signoffs yourself. That's a human gate. Your job
+is to recommend.
+
+## Session hygiene
+
+- **filigree workflow**: `bug` type uses states `triage → confirmed
+  [requires severity] → fixing [requires root_cause] → verifying
+  [requires fix_verification] → closed`. Required field per transition
+  is enforced; `filigree validate ` tells you what's missing.
+  Severity enum: `critical, major, minor, cosmetic`.
+- **Commit discipline**: one logical fix per commit. Commit message
+  cites the filigree ID verbatim (e.g. `Closes clarion-abcdef1234`).
+- **Never skip hooks** (`--no-verify`). If pre-commit fails, fix the
+  root cause and re-commit.
+- **ADR-023 gates on every commit** — not every N commits. If a
+  commit fails clippy pedantic, the fix is either a real code change
+  or an `#[allow(clippy::...)]` with a one-line justification comment.
+- **Never invent new ADRs** in this session. If a finding points at a
+  design-level gap, file the issue and let the user decide whether it
+  needs an ADR.
+- **No doc restructuring.** Read docs; don't rewrite them. If a spec
+  file actually contradicts the code, file an issue; don't silently
+  edit the spec to match.
+- **Respect the rename-over-stub policy** (CLAUDE.md): if anything
+  moves, use `git mv`; don't leave redirect stubs behind.
+
+## Starting checklist
+
+1. `git status && git log --oneline main..HEAD | head -25` — confirm
+   branch state matches this doc.
+2. `filigree list --label=wp:2 --status=open --json | jq .` — get the
+   current open tail.
+3. `cargo nextest run --workspace --all-features` — confirm green
+   baseline (expect 153 passing).
+4. Read the three most-recent commit bodies: `git log --format=%B
+   -n3`. Internalise the RunOutcome split and the breaker wiring
+   before reviewers start flagging them.
+5. Dispatch Phase 1 reviewers in parallel.
+
+Good luck. Assume the code is wrong until proven otherwise.
diff --git a/docs/superpowers/handoffs/2026-04-23-wp2-scrub-findings.md b/docs/superpowers/handoffs/2026-04-23-wp2-scrub-findings.md
new file mode 100644
index 00000000..83e948ac
--- /dev/null
+++ b/docs/superpowers/handoffs/2026-04-23-wp2-scrub-findings.md
@@ -0,0 +1,158 @@
+# WP2 scrub — findings triage (2026-04-23)
+
+Outcome of the Phase-1 review sweep requested by
+`docs/superpowers/handoffs/2026-04-23-wp2-full-scrub-handoff.md`.
+Six independent reviewers (Rust reviewer, threat analyst, unsafe
+auditor, test-suite reviewer, coverage-gap analyst, bug-triage
+specialist) ran over commit range `ad8d4ce..a1cc3be` (HEAD
+`sprint-1/wp2-plugin-host` = `a1cc3be`).
+
+This doc captures every finding, classifies it, and commits to a
+disposition. The **FIX-NOW** slate is what this session will close
+before A.2 can tick; **FILE-ONLY** entries get new filigree issues for
+Sprint 2+ with trigger conditions; **DISMISS** entries get closed or
+skipped with a rationale.
+
+## Summary verdict
+
+**A.2 is not ready to tick at session start.** Three findings
+contradict explicit A.2 signoff claims (A.2.3, A.2.7) and one corrupts
+run-row terminal state on any plugin-task panic.
+
+After the FIX-NOW slate (9 fixes, estimated 9 commits) and assuming a
+decision on the C1 discriminator below, A.2 is ready to recommend for
+tick.
+
+**Unsafe-block audit verdict: SOUND.** The single unsafe in
+`host.rs::spawn` (pre_exec setrlimit) passes async-signal-safety,
+no-alloc, no-drop, no-unwind, and failure-path checks. No action.
+
+## Disposition table
+
+| # | Finding | Source | Severity | Disposition | Notes |
+|---|---------|--------|----------|-------------|-------|
+| 1 | `spawn_blocking` JoinError in `analyze.rs:230` bypasses CommitRun/FailRun → `runs.status` stuck at `'running'` on plugin-task panic | Rust F1 | CRITICAL | **FIX-NOW** | Same category as 37b56d9 (exit-code decoupling); blocks A.2 |
+| 2 | `EntityCapExceeded` never reached through `analyze_file` in any test | Coverage 1 | HIGH | **FIX-NOW** | A.2.3 signoff requires positive+negative tests; host wiring (`host.rs:674–685`) untested |
+| 3 | Crash-loop breaker trip is mock-proves-itself (`breaker_06`) — never drives `analyze.rs` wiring added in `a1cc3be` | Test C-1 / Coverage 5 | HIGH | **FIX-NOW** | A.2.7 signoff literally states "test with `MockPlugin::new_crashing`"; existing test does not exercise production wiring |
+| 4 | `analyze_without_plugins_writes_skipped_run_row` leaks parent `$PATH` | Test C-2 | HIGH | **FIX-NOW** | Any machine with `clarion-plugin-fixture` installed fails this; A.1.8 reliability bomb |
+| 5 | `make_request` / `make_notification` in `protocol.rs:298,314` are `pub` with `.expect()` panic | Rust F3 | MEDIUM | **FIX-NOW** | Same shape as filed 0b1f8bc940 (`pub` footgun); 2-line visibility fix |
+| 6 | `walk_dir` silently swallows per-entry I/O errors (`analyze.rs:672–674`) | Rust F4 | MEDIUM | **FIX-NOW** | Same WP1 anti-pattern as `read_applied_versions`; `warn!` + counter |
+| 7 | T6 path-escape breaker test uses `any()` instead of pinning count to 10 | Test C-4 | MEDIUM | **FIX-NOW** | Bundle with existing `clarion-f45dd6056f` (T3 has the same weakness) — one commit, closes both |
+| 8 | T8 oversize tests cover only `qualified_name` + `source.file_path`; `id` and `kind` untested | Test C-5 | MEDIUM | **FIX-NOW** | Two new tests, no production change |
+| 9 | `t9_handshake_failure_exits_cleanly_without_hanging` claims more than it tests | Test C-3 | LOW | **FIX-NOW** | Rename + comment; no behaviour change |
+| 10 | Spawn uses `manifest.plugin.executable` not `DiscoveredPlugin.executable` | Threat C1 | HIGH (debate) | **USER DECISION** | Gates A.2.4's "L9 locked" if we accept the hybrid-authority framing |
+| 11 | clarion-6cde4f37d7 (JailError::Io/NonUtf8Path allegedly don't tick breaker) | prior review | — | **DISMISS** | `host.rs:658` calls `record_escape()` unconditionally after the 3-arm match; filed against older code |
+| 12 | clarion-c850c27f33 (`entities.len() as u64` unchecked cast) | prior review | — | **DISMISS** | `usize as u64` is lossless on 64-bit; clippy pedantic does not flag; purely cosmetic |
+| 13 | `read_frame` has no read deadline → CLI hangs forever on plugin hang | Rust F2 / Threat S1 | HIGH | **FILE-ONLY** | Requires per-op deadline + kill; design work, not quick fix. Trigger: first production run that hits a real-world plugin hang (or Sprint 2 hardening) |
+| 14 | `to_string_lossy` at wire boundary silently mangles non-UTF-8 paths | Rust F5 | MEDIUM | **FILE-ONLY** | Should fail loudly as `JailError::NonUtf8Path`; small fix, but changes an error surface |
+| 15 | `ontology_version` field not validated at handshake | Rust F6 | MEDIUM | **FILE-ONLY** | Retrofit cost when WP6 cache-keying lands. Trigger: WP6 kickoff |
+| 16 | `stderr = Stdio::inherit()` → plugin can DoS terminal, inject log lines | Threat C2 | MEDIUM | **FILE-ONLY** | Trigger: before community plugins are enabled |
+| 17 | `analyze_file` response body deep-cloned twice (`result_val.cloned()` + `from_value`) | Threat C3 | MEDIUM | **FILE-ONLY** | Structural fix: typed `AnalyzeFileResult`. Trigger: elspeth-scale ingest / WP4 |
+| 18 | `do_shutdown` response-id validation loses sync with queued plugin frames → breaker-kill path is best-effort | Threat C4 | MEDIUM | **FILE-ONLY** | Should discard-until-match with frame budget. Trigger: adversarial plugin threat model |
+| 19 | Response IDs not validated against outstanding set — stale frames surface as protocol errors on the next call | Threat C5 | LOW | **FILE-ONLY** | Mostly robustness; same fix as #18 |
+| 20 | No `RLIMIT_NOFILE` / `RLIMIT_NPROC` — plugin can fork/open-sockets unbounded during initialize | Threat P2 | MEDIUM | **FILE-ONLY** | Clear `pre_exec` addition; defer to decide default NPROC knob |
+| 21 | Discovery does not refuse world-writable PATH dirs / plugin.toml | Threat P3 | LOW | **FILE-ONLY** | Operator-env dependent; trigger = multi-tenant CI support |
+| 22 | Content-Length ceiling not exercised through `PluginHost` in any test | Coverage 3 | MEDIUM | **FILE-ONLY** | Test-only; drive `MockBehaviour::Oversize` through `PluginHost::connect` and assert `HostError::Transport` |
+| 23 | Cross-plugin identity fabrication (plugin A emits `id` with plugin B's prefix) not tested | Coverage 4 | MEDIUM | **FILE-ONLY** | Test only; T4 covers wrong `qualified_name`, not wrong `plugin_id` segment |
+| 24 | RLIMIT_AS kill not observed end-to-end (real subprocess exceeding limit) | Coverage 6 | LOW | **FILE-ONLY** | Deliberately hard test; unit + code review sufficient for Sprint 1 |
+| 25 | `HostError::Protocol` on response-id mismatch in `handshake` and `do_shutdown` untested | Coverage 7, 8 | LOW | **FILE-ONLY** | Same fix surface as #18/19 |
+| 26 | clarion-a287217267 P2 (extra maps unbounded) | existing | P2 | **FILE-ONLY** | Keep; part of Cluster A. Trigger: before first non-fixture plugin |
+| 27 | clarion-106ab51bc9, 920609be1f, 0b1f8bc940 (deserialisation ceilings) | existing | P3 | **FILE-ONLY** | Cluster A batch; fix together |
+| 28 | clarion-928349b60f, 5164e4990b (canonical-vs-raw paths) | existing | P3 | **FILE-ONLY** | Cluster B; WP4 briefing-read trigger |
+| 29 | clarion-e190f1e72b, 5578157797 (missing assertions) | existing | P3 | **FILE-ONLY** | Cluster C test-quality batch, Sprint 2 open |
+| 30 | clarion-f30acbbb31 (double-shutdown guard) | existing | P3 | **FILE-ONLY** | Polish; documentary guard works today |
+| 31 | clarion-48c5d06578 (poisoned-inbox drain strategy) | existing | P3 | **FILE-ONLY** | Forward-flag for WP4 supervisor |
+
+## Cluster root-cause notes
+
+- **Cluster A — "serde boundary with no ceiling"** (#14 omitted — that's a different class). Members: 26, 27. Fix pattern: one helper `bounded_deserialize` applied at every plugin-controlled serde entry point. Single-policy review.
+- **Cluster B — "canonical-vs-raw path discipline"**. Members: 28. Fix pattern: canonicalize-once + reopen-against-pinned-root-fd for future briefing reads.
+- **Cluster C — "tests are existential where they should be universal/negative"**. Members: 29, plus this session's #7 (T6 count pinning). After the T6 fix + closing f45dd6056f, the discipline is established; the remaining two issues become mechanical.
+
+## FIX-NOW slate — ordered
+
+Each item ships as one commit. TDD discipline: failing test first, then
+code. ADR-023 gates green on every commit (fmt, clippy pedantic,
+nextest, doc, deny).
+
+1. **Finding #1** — JoinError handling in `analyze.rs`. New filigree
+   issue first (CRITICAL bug). Test: inject a panic into the plugin-task
+   path (simplest: a test-only seam in `run_plugin_blocking` that panics
+   before return), assert `runs.status='failed'` + exit 1.
+2. **Finding #3** — E2E crash-loop breaker trip test. New filigree
+   issue. Mock plugin that crashes every call + 4+ input files, run
+   `clarion analyze`, assert `FINDING_DISABLED_CRASH_LOOP` and subsequent
+   plugins skipped.
+3. **Finding #2** — `EntityCapExceeded` host-level test. New filigree
+   issue. Construct `PluginHost` with `EntityCountCap::new(2)`, feed
+   mock emitting 3 entities, assert `HostError::EntityCapExceeded` and
+   `FINDING_ENTITY_CAP_EXCEEDED` finding present.
+4. **Finding #4** — PATH leak fix in `analyze_without_plugins_...`.
+   New filigree issue. Add `.env("PATH", "")` to both install and
+   analyze invocations.
+5. **Finding #7** — T6 + T3 count pinning. Closes existing
+   f45dd6056f plus a new sibling issue for T6. Replace `any(...)` with
+   `filter(...).count() == 10` in both tests.
+6. **Finding #8** — T8c + T8d for `id` and `kind` oversize. New
+   filigree issue. Two small additional tests mirroring T8b.
+7. **Finding #5** — `pub(crate)` on `make_request`/`make_notification`.
+   New filigree issue. Change visibility; update imports; no test change
+   (existing tests cover the call sites).
+8. **Finding #6** — `walk_dir` warn+counter. New filigree issue. Log
+   skipped entries; include skip count in run summary log line.
+9. **Finding #9** — rename `t9_...` test. New filigree issue. Rename
+   and comment-clarify what it actually asserts.
+
+## Disposition discipline
+
+- Each FIX-NOW finding gets its own filigree issue created first (so
+  the commit can cite it) and closed with the fix commit SHA in
+  `fix_verification`.
+- Each FILE-ONLY finding gets one new filigree issue per row (except
+  existing ones #26–#31, which are kept open). Issues use
+  `--label=sprint:1 --label=wp:2` plus any ADR labels. Trigger condition
+  in the issue body.
+- clarion-6cde4f37d7 closes with a comment pointing at `host.rs:658`.
+- clarion-c850c27f33 closes with a comment explaining `usize as u64`
+  is lossless on 64-bit (or stays open as a chore if the user wants).
+
+## Decision needed from the user — Threat C1
+
+**Finding #10** is the discriminating call. `host.rs:382` constructs
+`Command::new(&manifest.plugin.executable)` — the host runs whatever
+path the manifest names, not the `clarion-plugin-*` binary discovery
+found on `$PATH`. A malicious or simply misconfigured `plugin.toml`
+can put `executable = "/bin/sh"` or `executable = "python3"` and the
+host will run that.
+
+**Two framings**:
+
+- **Operator-trust**: plugin.toml ships alongside the binary the
+  operator installed; they chose both. Not an escalation beyond what
+  the plugin could do anyway.
+- **Hybrid-authority (ADR-021 spirit)**: core enforces minimums against
+  a semi-trusted plugin. "Run the binary we discovered, not an arbitrary
+  manifest path" belongs in the same minimum set as RLIMIT_AS and path
+  jail.
+
+**Ask the user before fixing**. If they want it fixed now, it's one
+more commit (~50 lines, thread `DiscoveredPlugin.executable` or the
+canonical path into `PluginHost::spawn`, refuse manifests where
+`executable` contains `/` or differs from discovered basename). If
+not, file it with "A.2.4 lock pending — requires either a fix or an
+explicit ADR-021 addendum naming the exclusion."
+
+## A.2 recommendation
+
+Provisional, after the FIX-NOW slate lands and assuming the user
+disposition on C1:
+
+- **A.2.1–A.2.2, A.2.4–A.2.5, A.2.8, A.2.10–A.2.12**: ready to tick.
+- **A.2.3**: ready after Finding #2 (EntityCapExceeded test) lands.
+- **A.2.6**: ready (T4 identity-mismatch test exists; count-pinning
+  weakness is test-quality, not coverage).
+- **A.2.7**: ready after Finding #3 (E2E crash-loop test) lands.
+- **A.2.9** (UQ-WP2-* resolved): doc-only check pending.
+
+Do **not** tick them yourself — the user/human owns the sprint-level
+lock-in stamp.
diff --git a/docs/superpowers/handoffs/2026-04-24-post-wp2-scrub-handoff.md b/docs/superpowers/handoffs/2026-04-24-post-wp2-scrub-handoff.md
new file mode 100644
index 00000000..4a29f59f
--- /dev/null
+++ b/docs/superpowers/handoffs/2026-04-24-post-wp2-scrub-handoff.md
@@ -0,0 +1,363 @@
+# Clarion Sprint 1 — post-WP2-scrub handoff
+
+This file is the starting prompt for the next Claude Code session after
+the WP2 phase-3 scrub closed out on 2026-04-24. Paste it as the user's
+first message; it is self-contained.
+
+Supersedes `2026-04-23-wp2-full-scrub-handoff.md`. That scrub is
+complete: WP2 code-complete, 21 outstanding issues closed, 5 remaining
+all legitimately FILE-ONLY with documented deferral rationale.
+
+---
+
+# Continue Clarion Sprint 1 — WP2 signoff close + WP3 kickoff
+
+WP2 is code-complete and ready to tick. WP3 (Python plugin) is the
+next work package. This session's job is the human-gated A.2 signoff
+close AND/OR starting WP3 — your choice depending on what the user
+wants to do first.
+
+Do not assume the WP2 scrub was exhaustive beyond what's claimed here.
+It was time-boxed and focused on what the reviewers flagged; a fresh
+perspective on WP3 will inevitably surface things WP2 missed.
+
+## Working directory + branch
+
+- Directory: `/home/john/clarion`
+- Branch: `sprint-1/wp2-plugin-host`
+- Current HEAD: `7c0e396` (last WP2 scrub commit — world-writable dir
+  refusal)
+- Merge base with `main`: `ad8d4ce`
+- 45 commits on this branch total (21 original WP2 + triage doc + 23
+  scrub commits across two push-through rounds)
+- 174 tests passing; every ADR-023 gate green on every commit (fmt,
+  clippy pedantic, nextest, doc -D warnings, deny)
+
+## What happened in the scrub (do not redo)
+
+Six parallel reviewers (rust-code-reviewer, threat-analyst,
+unsafe-auditor, test-suite-reviewer, coverage-gap-analyst,
+bug-triage-specialist) swept `ad8d4ce..a1cc3be`. Triage doc at
+`docs/superpowers/handoffs/2026-04-23-wp2-scrub-findings.md`. 21
+issues closed across the scrub:
+
+### First round — blocking items (session 1)
+
+1. `b30638c` — `spawn_blocking` JoinError no longer bypasses
+   CommitRun/FailRun (plugin-task panic → `runs.status='failed'`).
+2. `7f8fc9a` — E2E crash-loop breaker trip test (A.2.7).
+3. `3e0ea44` — host-level `EntityCapExceeded` test T9 (A.2.3).
+4. `ad054bd` — PATH scrubbed in skipped-run test.
+5. `669e89e` — `protocol::make_{request,notification}` → `pub(crate)`.
+6. `d890a73` — `walk_dir` logs + counts skipped entries.
+7. `483db6e` — T3 + T6 exact-count assertions, T8c + T8d for id/kind
+   oversize.
+8. `26f14aa` — `t9_handshake_failure_…_returns_err_promptly` rename.
+
+### Second round — FILE-ONLY follow-ups (session 2)
+
+Cluster A deserialisation ceilings:
+
+9. `288defe` — `ContentLengthCeiling::unbounded()` gated behind
+   `#[cfg(test)]`; fixture uses `DEFAULT` (8 MiB).
+10. `855803e` — `plugin.toml` capped at 64 KiB; `[integrations.*]`
+    capped at 64 entries; `DiscoveryError::ManifestTooLarge` variant.
+11. `7d97c66` — `ProtocolError.message/.data` truncated at 4 KiB via
+    custom Deserialize (`MAX_PROTOCOL_ERROR_FIELD_BYTES`).
+12. `7b5db34` — `RawEntity.extra` / `RawSource.extra` bounded by
+    serialised size (`MAX_ENTITY_EXTRA_BYTES = 64 KiB`); T8e.
+
+Cluster B + protocol hardening:
+
+13. `84b5778` — documented that `DiscoveredPlugin.executable` is
+    raw-PATH (neighbour-manifest constraint); no behaviour change.
+14. `6b0fa3a` — structural double-shutdown guard (`terminated: bool`);
+    T8f.
+15. `5fb5666` — `FINDING_NON_UTF8_PATH` at wire boundary; T8g.
+16. `1ac32b1` — `ontology_version` validated at handshake; stored on
+    `PluginHost`; pub `ontology_version()` getter (ADR-007 / WP6 prep).
+17. `769a177` — **drain-until-match** helper
+    `read_response_matching` replaces single-read in all three
+    response sites (handshake, analyze_file, do_shutdown);
+    `MAX_DRAIN_FRAMES = 16` bounds the budget.
+18. `bd92600` — `RLIMIT_NOFILE=256` + `RLIMIT_NPROC=32` applied in
+    pre_exec; `apply_prlimit_nofile_nproc`.
+
+Threat C1 (user-green-lit):
+
+19. `eb0a41d` — **`PluginHost::spawn` signature changed** — now takes
+    `executable: &Path` separately; manifest's `plugin.executable`
+    must be a bare basename matching the discovered binary; T10 +
+    T11 negative tests. **This is a breaking change for any caller
+    in WP3+.**
+
+Test gaps + cleanup:
+
+20. `89b2da0` — analyze-file-error-payload, content-length-ceiling-
+    through-host, cross-plugin-id-spoof, drain-happy-path,
+    no-initialized-after-refusal (T2 strengthened).
+21. `b4eca5b` — typed `AnalyzeFileResult` in `analyze_file` — eliminates
+    the outer Value-array clone.
+22. `b3c91a7` — **stderr piped to bounded ring buffer** (64 KiB);
+    `host.stderr_tail() -> Option` for diagnostics; T9b.
+    **Also a caller-observable change — stderr is no longer inherited.**
+23. `7c0e396` — world-writable `$PATH` dirs refused with
+    `DiscoveryError::WorldWritableDir`; T8.
+
+## WP2 signoff ladder (§A.2) status at handoff
+
+All 12 boxes have production code AND a discriminating test. You can
+tick them pending the human-gate rules below.
+
+| Item | Proof that's in place |
+|---|---|
+| A.2.1 | `transport_*` tests + end-to-end via T1 |
+| A.2.2 | `manifest.rs` tests (30+ positive/negative) |
+| A.2.3 | T9 (entity cap host-level) + `content_length_ceiling_surfaces_through_plugin_host` + T5/T6 (jail + breaker) + `apply_prlimit_linux_returns_ok` |
+| A.2.4 | `discovery.rs` T1–T8 + T10/T11 spawn-safety |
+| A.2.5 | T3 with pinned count |
+| A.2.6 | T4 + `cross_plugin_plugin_id_spoof_is_rejected` |
+| A.2.7 | `breaker_*` + `wp2_crash_loop_breaker_trips_and_skips_remaining_plugins` |
+| A.2.8 | `wp2_e2e_smoke_fixture_plugin_round_trip` |
+| A.2.9 | **DOC TASK — not done** (see below) |
+| A.2.10 | manifest negative tests for grammar + reserved prefix |
+| A.2.11 | manifest negative tests for reserved kinds |
+| A.2.12 | T2 with strengthened no-initialized-sent assertion |
+
+## The 5 remaining WP2 open issues — **do not reopen**
+
+Each has an explicit trigger or deferral rationale from the scrub's
+advisor pass. Re-litigating them is time wasted.
+
+| ID | Why it stays open |
+|---|---|
+| `clarion-9dee2d24c3` P1 | WP2 umbrella — human gate; closes when A.2 ticks |
+| `clarion-48c5d06578` P3 | Explicit WP4 trigger: "flag when Task 6 writes its own supervisor read loop" |
+| `clarion-928349b60f` P3 | Explicit WP4 trigger: becomes critical when briefing-serving code reads `AcceptedEntity.source_file_path` |
+| `clarion-35688034f0` P3 | Deferred: touches every I/O path, needs cross-module design pass. Half-fixing is worse than not fixing — do in its own session when someone has the design bandwidth |
+| `clarion-c0977ac293` P4 | Deferred: deliberately hard (requires a subprocess that allocates past limit); unit tests + code review of `reap_and_classify_exit` sufficient for Sprint 1 |
+
+## Your job this session — two options
+
+### Option A: A.2 signoff close (tight, ~30 min)
+
+Complete the human-gate steps so WP2 is formally locked.
+
+1. **A.2.9 doc walk-through**: read
+   `docs/implementation/sprint-1/wp2-plugin-host.md §5` and verify
+   every UQ-WP2-* is marked resolved. Where one isn't, either update
+   the doc or surface the gap. UQ-WP2-10 specifically should read
+   "resolved by ADR-002"; UQ-WP2-11 should read "resolved by identity
+   check / T4".
+2. **Tick A.2.1–A.2.12** in
+   `docs/implementation/sprint-1/signoffs.md`. Each tick gets a
+   `locked on 2026-04-24` (or current date) stamp where appropriate
+   (L4, L5, L6, L9 are the load-bearing lock-ins).
+3. **Close the umbrella** `clarion-9dee2d24c3` with a pointer to the
+   signoff commit.
+4. Make one commit: `docs(wp2): lock A.2 signoffs; close WP2 umbrella`.
+
+Do NOT tick A.3 or A.4 — those belong to WP3 and the demo respectively.
+
+### Option B: Start WP3 (Python plugin)
+
+Anchor doc: `docs/implementation/sprint-1/wp3-python-plugin.md`.
+
+This is the bigger slice. WP3 builds an editable Python package at
+`plugins/python/` that speaks the Sprint-1 JSON-RPC protocol. Key
+lock-ins:
+
+- **L7**: qualname reconstruction per
+  `docs/clarion/v0.1/detailed-design.md §§4–5` (module-level, nested,
+  class, async, nested-class). Shared test fixture at
+  `/fixtures/entity_id.json` must pass byte-for-byte in both Rust and
+  Python — this is the L2+L7 alignment proof (A.3.4).
+- **L8**: Wardline probe returns the three documented states
+  (`absent`, `enabled`, `version_out_of_range`).
+- ADR-023 Python gates: `ruff check`, `ruff format --check`,
+  `mypy --strict`, `pytest` all green.
+
+You will need Option A done first OR done alongside — A.3 tests
+exercise the full host↔plugin pipeline and will surface any latent
+WP2 bug.
+
+### Caller-observable WP2 changes WP3 must know
+
+The scrub changed several surfaces that WP3 will touch:
+
+1. **`PluginHost::spawn` signature**: now takes `executable: &Path` as
+   a third argument (the discovered binary path). Manifest's
+   `plugin.executable` must be a bare basename that matches the
+   discovered filename, or `HostError::Spawn` fires before exec. WP3's
+   Python plugin must declare `executable = "clarion-plugin-python"`
+   — no paths.
+
+2. **Plugin's stderr is piped, not inherited.** The Python plugin's
+   stderr is captured into a 64 KiB ring buffer, accessible via
+   `host.stderr_tail()`. Print-debugging from the Python plugin during
+   tests won't show up in the test runner's stderr unless someone
+   asks for the tail. `tracing::warn!` in the host IS still on stdout
+   via `tracing_subscriber::fmt::init()`.
+
+3. **`ontology_version` must be present and non-empty** in the
+   `initialize` response. Python plugin's `initialize` handler needs
+   to return a valid semver string. The host validates and stores it;
+   WP6 will consume it via `host.ontology_version()`.
+
+4. **Drain-until-match** on response reads. If the Python plugin
+   accidentally sends a response twice (or sends unsolicited frames
+   between analyze_file calls), the host will log `warn!` and drain
+   up to `MAX_DRAIN_FRAMES = 16` stale frames before failing. Don't
+   write tests that rely on the host aborting on the first mismatched
+   id — that's not current behaviour.
+
+5. **Resource limits on the plugin child**: `RLIMIT_AS` from manifest's
+   `expected_max_rss_mb` (min of that and 2 GiB default), plus fixed
+   `RLIMIT_NOFILE = 256`, `RLIMIT_NPROC = 32`. The Python plugin boot
+   path (`python3 -m clarion_plugin_python`) must fit — CPython alone
+   takes ~30 MiB, plus imports. Realistic RSS ceiling for the Python
+   plugin is 256 MiB+ (set in `plugin.toml`).
+
+6. **Entity field caps**: `MAX_ENTITY_FIELD_BYTES = 4 KiB` per scalar
+   (`id`, `kind`, `qualified_name`, `source.file_path`);
+   `MAX_ENTITY_EXTRA_BYTES = 64 KiB` for `extra` / `source.extra`
+   serialised. A Python plugin emitting a huge docstring in `extra`
+   WILL have the entity dropped with `FINDING_ENTITY_FIELD_OVERSIZE`.
+   WP3 tests should assert on normal-size entities only.
+
+7. **`[integrations.*]` capped at 64 entries**; `plugin.toml` capped at
+   64 KiB. Python plugin's manifest should be ~2 KiB, well under.
+
+8. **world-writable `$PATH` dirs are refused**. In tests that use
+   TempDir on a 0o700 home, this isn't an issue — but CI systems that
+   drop plugins in a world-writable dir will now fail discovery with
+   `DiscoveryError::WorldWritableDir`.
+
+## Files of interest
+
+### WP2 (what's in place, don't edit unless fixing a bug)
+
+- `crates/clarion-core/src/plugin/host.rs` (~2400 lines; T1–T11 tests
+  plus 4 new coverage-gap tests)
+- `crates/clarion-core/src/plugin/protocol.rs` (ProtocolError has
+  custom Deserialize now; `AnalyzeFileResult` is typed)
+- `crates/clarion-core/src/plugin/discovery.rs` (world-writable check,
+  size caps)
+- `crates/clarion-core/src/plugin/manifest.rs` (integrations cap)
+- `crates/clarion-core/src/plugin/limits.rs` (NOFILE/NPROC)
+- `crates/clarion-cli/src/analyze.rs` (JoinError handled;
+  `DiscoveredPlugin.executable` threaded into spawn)
+
+### WP3 (what you'll create)
+
+- `plugins/python/` — editable Python package; `pyproject.toml` with
+  `ruff`, `mypy`, `pytest` configured per ADR-023
+- `plugins/python/clarion_plugin_python/` — the module
+- `plugins/python/tests/` — `test_qualname.py`,
+  `test_wardline_probe.py`, `test_server.py`, `test_round_trip.py`
+- Shared fixture used by both Rust and Python:
+  `fixtures/entity_id.json` (should already exist from WP1 — A.1.4)
+
+### Anchoring documents
+
+- `docs/implementation/sprint-1/wp3-python-plugin.md` — the WP doc
+- `docs/implementation/sprint-1/signoffs.md §A.3` — the gate
+- `docs/implementation/sprint-1/README.md §4` — lock-in table
+- `docs/clarion/adr/ADR-001-language-plugin-boundary.md`
+- `docs/clarion/adr/ADR-002-crash-loop-breaker.md`
+- `docs/clarion/adr/ADR-003-entity-id-format.md`
+- `docs/clarion/adr/ADR-007-summary-cache-keying.md` — you'll produce
+  `ontology_version` but not consume it yet
+- `docs/clarion/adr/ADR-023-tooling-baseline.md` — Python tooling gates
+- `docs/clarion/v0.1/detailed-design.md §§4–5` — qualname rules
+- `docs/clarion/v0.1/requirements.md` — REQ-/NFR- IDs WP3 addresses
+
+## Methodology
+
+Same as the prior sessions. Not negotiable.
+
+### Phase 1 — Plan + brainstorm (before writing code)
+
+Invoke the appropriate skills:
+- `superpowers:brainstorming` if WP3's shape is genuinely open (the
+  WP doc does most of this but you may have fresh angles)
+- `superpowers:writing-plans` after brainstorming, to produce a
+  concrete task list
+- `axiom-planning:review-plan` if the plan is non-trivial
+
+The WP3 doc is detailed; you probably don't need brainstorm. Go
+straight to plan + review if comfortable.
+
+### Phase 2 — TDD implementation
+
+One commit per task. Each commit:
+1. Failing test first (TDD discipline).
+2. Minimum code to pass.
+3. Gate run: all ADR-023 gates green before commit.
+4. Commit message cites the filigree issue ID.
+
+ADR-023 Rust gates (same as WP2):
+```
+cargo fmt --all -- --check
+cargo clippy --workspace --all-targets --all-features -- -D warnings
+cargo nextest run --workspace --all-features
+RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
+cargo deny check
+```
+
+ADR-023 Python gates (new for WP3):
+```
+ruff check plugins/python/
+ruff format --check plugins/python/
+mypy --strict plugins/python/
+pytest plugins/python/
+```
+
+### Phase 3 — Demo script + close
+
+- Run the README §3 demo script end-to-end
+  (`docs/implementation/sprint-1/README.md §3`).
+- Tick A.3.1–A.3.10 in the signoff ladder with a `locked on `
+  stamp where appropriate.
+- Tick A.4.1–A.4.3 (end-to-end walking skeleton).
+- Close the WP3 umbrella issue and the Sprint 1 close issue.
+
+## Session hygiene
+
+- **filigree workflow**: MCP tools are in `.mcp.json`. Prefer
+  `mcp__filigree__*` over CLI.
+- **Do not reopen the 5 remaining WP2 issues** — each has a documented
+  deferral rationale. If WP3 work genuinely uncovers a reason to
+  revisit (e.g. Python plugin hangs in a way only `read_frame`
+  deadline would fix), file a new issue and reference the old one.
+- **Commit discipline**: one logical fix per commit, `git add
+  ` (not `git add -A`). The WP2 scrub had one
+  accidental mis-stage from `-A`; the commit stayed but I flagged it
+  to the user.
+- **Never skip hooks** (`--no-verify`). If a pre-commit fails, fix
+  the root cause.
+- **ADR-023 gates on every commit**. Not every N commits.
+- **Never invent new ADRs** in this session. If WP3 work surfaces a
+  design-level decision, file an issue and let the user decide.
+- **Respect the rename-over-stub policy** (CLAUDE.md): if anything
+  moves, use `git mv`.
+
+## Starting checklist
+
+1. `git status && git log --oneline main..HEAD | head -10` — confirm
+   branch state matches this doc (HEAD should be `7c0e396`).
+2. `filigree list --status=open --json | jq -r '.[] | "\(.id) P\(.priority) \(.type) \(.title)"' | head -30` —
+   see what's ready. WP2 tail (5 items) is legitimately open; expect
+   `clarion-cd84959ee9` (WP3 umbrella) ready.
+3. `cargo nextest run --workspace --all-features 2>&1 | tail -5` —
+   confirm the 174-test green baseline.
+4. Read this doc in full. Read the WP3 doc + signoffs §A.3 + ADR-023's
+   Python section.
+5. Ask the user: "A.2 close, WP3 start, or both in parallel?" The
+   answer determines your first task list.
+6. If they want both: tick A.2 as Task 1 (small commit), then pivot to
+   WP3. Don't conflate the two.
+
+Good luck. Assume WP2 is correct until proven otherwise — the scrub
+was thorough but not infallible, and WP3 tests will find anything it
+missed.
diff --git a/docs/superpowers/plans/2026-04-18-wp1-scaffold-storage.md b/docs/superpowers/plans/2026-04-18-wp1-scaffold-storage.md
new file mode 100644
index 00000000..eb2819c6
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-18-wp1-scaffold-storage.md
@@ -0,0 +1,3395 @@
+# WP1 — Scaffold + Storage Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship the Sprint 1 walking-skeleton storage foundation — Cargo workspace, full SQLite schema migration, writer-actor with per-N-batch transactions, entity-ID assembler, and `clarion install` + `clarion analyze` CLI skeletons. Plugin spawning is WP2's concern; Sprint 1 WP1 must exit with `runs.status = 'skipped_no_plugins'`.
+
+**Architecture:** Three-crate Cargo workspace: `clarion-core` (domain types + entity-ID + LlmProvider trait stub), `clarion-storage` (SQLite layer + writer-actor over a bounded tokio mpsc channel per ADR-011), `clarion-cli` (binary). Writer-actor is a `tokio::task` owning the sole write `rusqlite::Connection`; readers come from a `deadpool-sqlite` pool. Full schema from `detailed-design.md §3` ships in migration `0001_initial_schema.sql` even though Sprint 1 only writes `entities` + `runs`. The design pressure is applied now so Sprint 2+ doesn't face data-migration work. **Tooling baseline per ADR-023** lands with Task 1 before any other code — edition 2024, workspace `[lints]` pedantic, rustfmt/clippy configs, cargo-nextest, cargo-deny, GitHub Actions CI — so every subsequent commit passes the strict floor from day one.
+
+**Tech Stack:** **Rust 2024** stable (ADR-023); workspace `[lints]` with `clippy::pedantic = "warn"` + `unsafe_code = "forbid"`; `rusqlite` (bundled SQLite); `deadpool-sqlite`; `tokio` (rt-multi-thread, macros, sync); `clap` (CLI); `thiserror` (library errors); `anyhow` (binary); `tracing` + `tracing-subscriber`; `assert_cmd` + `tempfile` (CLI integration tests); **`cargo-nextest`** (test runner); **`cargo-deny`** (supply chain); **GitHub Actions** (CI gates).
+
+**Source spec:** `docs/implementation/sprint-1/wp1-scaffold.md`. This plan is its TDD execution walk. If the two disagree, the spec is authoritative on *what* to build; this plan is authoritative on *how* to build it step-by-step.
+
+**ADR anchors:** ADR-001 (Rust + rusqlite + tokio), ADR-003 (entity-ID 3-segment form), ADR-011 (writer-actor + per-N-batch + PRAGMA set), ADR-022 (grammar on `plugin_id` and `kind`), **ADR-023 (tooling baseline — edition 2024, pedantic, cargo-deny, nextest, CI)**. ADR-005 is authored as a side effect of Task 5; ADR-023 is pre-authored and lands verbatim in Task 1.
+
+**Resolved UQs before starting:**
+- **UQ-WP1-01** rusqlite + bundled SQLite (ADR-011).
+- **UQ-WP1-02** tokio from day one (ADR-011).
+- **UQ-WP1-03** per-command oneshot ack. Commit-counter test hook uses an `Arc` threaded through `Writer::spawn` — keeps the hook path identical in release and test builds; no `#[cfg(test)]` branches in the hot loop.
+- **UQ-WP1-04** `.gitignore` seeded with: `tmp/`, `logs/`, `*.shadow.db`, `*.wal`, `*.shm`, `runs/*/log.jsonl`. Tracked: `clarion.db`, `config.json`, schema history in the DB.
+- **UQ-WP1-05** `runs` row shape matches `detailed-design.md §3:695-701` fully; Sprint 1 inserts NULL/JSON-`{}` for plugin-invocation columns, WP2 fills them.
+- **UQ-WP1-06** `clarion-storage` wraps `rusqlite::Error` in a crate-local `StorageError` via `thiserror`.
+- **UQ-WP1-07** assembler rejects any segment containing `:` with an `EntityIdError::SegmentContainsColon` — documents the grammar contract as a type-checked invariant.
+- **UQ-WP1-08** `clarion install` refuses if `.clarion/` exists without `--force`; `--force` is recognised in clap but returns `unimplemented in Sprint 1` at runtime.
+- **UQ-WP1-09** **reopened and re-resolved by ADR-023**: Rust **edition 2024**, workspace `[lints]` block with `clippy::pedantic = "warn"` + `unsafe_code = "forbid"`, pinned `rustfmt.toml` + `clippy.toml`, **`cargo-nextest`** as the test runner, **`cargo-deny`** for supply-chain hygiene, **GitHub Actions CI** running fmt-check + pedantic clippy + nextest + cargo-doc + cargo-deny on every PR. `rust-toolchain.toml` pins `stable` + `clippy`/`rustfmt`/`llvm-tools-preview`. The original "fine to document and move on" framing was the tell for unexamined tech debt; adopted at the zero-code frontier where retrofit cost is zero.
+
+**Scope note on entity-ID format:** ADR-003 fixes the 3-segment form as `{plugin_id}:{kind}:{canonical_qualified_name}`. The assembler (Task 2) validates `plugin_id` + `kind` against the ADR-022 grammar (`[a-z][a-z0-9_]*`) and rejects any segment containing `:`. It is **format-agnostic on `canonical_qualified_name`** — that segment's internal shape is the emitting plugin's concern (Python plugin: dotted qualname; core file-discovery: `{hash}@{path}`; etc.). Sprint 1 tests use simplified example strings to exercise concatenation; the plugin-specific shapes are validated by WP3 + the core file-discovery pass post-Sprint-1.
+
+---
+
+## Task 1: Workspace skeleton + ADR-023 tooling baseline
+
+**Files:**
+- Create: `/home/john/clarion/Cargo.toml` (workspace root, `[workspace.package]`, `[workspace.dependencies]`, `[workspace.lints]`)
+- Create: `/home/john/clarion/rust-toolchain.toml`
+- Create: `/home/john/clarion/rustfmt.toml`
+- Create: `/home/john/clarion/clippy.toml`
+- Create: `/home/john/clarion/deny.toml`
+- Create: `/home/john/clarion/.github/workflows/ci.yml`
+- Create: `/home/john/clarion/.gitignore`
+- Create: `/home/john/clarion/crates/clarion-core/Cargo.toml`
+- Create: `/home/john/clarion/crates/clarion-core/src/lib.rs`
+- Create: `/home/john/clarion/crates/clarion-storage/Cargo.toml`
+- Create: `/home/john/clarion/crates/clarion-storage/src/lib.rs`
+- Create: `/home/john/clarion/crates/clarion-cli/Cargo.toml`
+- Create: `/home/john/clarion/crates/clarion-cli/src/main.rs`
+
+- [ ] **Step 1: Create the workspace root `Cargo.toml`**
+
+Write `/home/john/clarion/Cargo.toml`:
+
+```toml
+[workspace]
+resolver = "3"
+members = [
+    "crates/clarion-core",
+    "crates/clarion-storage",
+    "crates/clarion-cli",
+]
+
+[workspace.package]
+version = "0.1.0-dev"
+edition = "2024"
+license = "MIT OR Apache-2.0"
+repository = "https://github.com/qacona/clarion"
+rust-version = "1.85"
+
+[workspace.lints.rust]
+unsafe_code = "forbid"
+
+[workspace.lints.clippy]
+pedantic = { level = "warn", priority = -1 }
+# Pragmatic allows per ADR-023 — revisit per WP if the floor gets too loud.
+module_name_repetitions = "allow"
+must_use_candidate = "allow"
+missing_errors_doc = "allow"
+
+[workspace.dependencies]
+anyhow = "1"
+clap = { version = "4", features = ["derive"] }
+deadpool-sqlite = { version = "0.8", features = ["rt_tokio_1"] }
+rusqlite = { version = "0.31", features = ["bundled"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+thiserror = "1"
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+assert_cmd = "2"
+tempfile = "3"
+```
+
+The `resolver = "3"` value is required for edition 2024. Priority `-1` on `clippy::pedantic` lets the pragmatic allows override individual pedantic lints correctly (the `level = "warn"` group takes priority `0` by default, which means individual allow-lints of the same group would otherwise lose the tie).
+
+- [ ] **Step 2: Pin the toolchain**
+
+Write `/home/john/clarion/rust-toolchain.toml`:
+
+```toml
+[toolchain]
+channel = "stable"
+components = ["rustfmt", "clippy", "llvm-tools-preview"]
+profile = "minimal"
+```
+
+`llvm-tools-preview` is carried from Task 1 onward per ADR-023 so `cargo install cargo-llvm-cov` works first try in a later WP without a retrofit.
+
+- [ ] **Step 3: Write `rustfmt.toml`**
+
+Write `/home/john/clarion/rustfmt.toml`:
+
+```toml
+edition = "2024"
+max_width = 100
+newline_style = "Unix"
+use_field_init_shorthand = true
+use_try_shorthand = true
+```
+
+- [ ] **Step 4: Write `clippy.toml`**
+
+Write `/home/john/clarion/clippy.toml`:
+
+```toml
+cognitive-complexity-threshold = 15
+too-many-arguments-threshold = 8
+too-many-lines-threshold = 120
+```
+
+- [ ] **Step 5: Write `deny.toml`**
+
+Write `/home/john/clarion/deny.toml`:
+
+```toml
+# deny.toml — cargo-deny v2 schema. Anything not in `allow` is denied.
+
+[advisories]
+version = 2
+yanked = "deny"
+ignore = []
+
+[licenses]
+version = 2
+allow = [
+    "MIT",
+    "Apache-2.0",
+    "Apache-2.0 WITH LLVM-exception",
+    "BSD-2-Clause",
+    "BSD-3-Clause",
+    "ISC",
+    "Unicode-3.0",
+    "Unicode-DFS-2016",
+]
+confidence-threshold = 0.8
+
+[bans]
+multiple-versions = "warn"
+wildcards = "deny"
+
+[sources]
+unknown-registry = "deny"
+unknown-git = "deny"
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]
+allow-git = []
+```
+
+- [ ] **Step 6: Write the GitHub Actions CI workflow**
+
+Write `/home/john/clarion/.github/workflows/ci.yml`:
+
+```yaml
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+
+env:
+  CARGO_TERM_COLOR: always
+  RUSTFLAGS: "-D warnings"
+
+jobs:
+  rust:
+    name: Rust
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: dtolnay/rust-toolchain@stable
+        with:
+          components: clippy, rustfmt
+
+      - uses: Swatinem/rust-cache@v2
+
+      - name: fmt
+        run: cargo fmt --all -- --check
+
+      - name: clippy
+        run: cargo clippy --workspace --all-targets --all-features -- -D warnings
+
+      - name: install cargo-nextest
+        uses: taiki-e/install-action@cargo-nextest
+
+      - name: test
+        run: cargo nextest run --workspace --all-features
+
+      - name: doc
+        run: cargo doc --workspace --no-deps --all-features
+
+      - name: install cargo-deny
+        uses: taiki-e/install-action@cargo-deny
+
+      - name: deny
+        run: cargo deny check
+```
+
+`python-plugin` job is added by WP3 Task 1; Sprint-1 WP1 ships with the Rust job only.
+
+- [ ] **Step 7: Write the repo-root `.gitignore`**
+
+Write `/home/john/clarion/.gitignore`:
+
+```
+/target
+**/*.rs.bk
+Cargo.lock.bak
+
+# SQLite working files (project-level .clarion/ is tracked per ADR-005)
+*.db-journal
+*.db-wal
+
+# Rust-analyzer / IDE caches
+/.idea
+/.vscode
+```
+
+Note: we do **not** ignore `*.db` here. `.clarion/clarion.db` is tracked per ADR-005 (authored in Task 5); only write-ahead files are excluded.
+
+- [ ] **Step 8: Write `clarion-core`'s `Cargo.toml` and `lib.rs`**
+
+Write `/home/john/clarion/crates/clarion-core/Cargo.toml`:
+
+```toml
+[package]
+name = "clarion-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+rust-version.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+serde.workspace = true
+serde_json.workspace = true
+thiserror.workspace = true
+```
+
+Write `/home/john/clarion/crates/clarion-core/src/lib.rs`:
+
+```rust
+//! clarion-core — domain types, identifiers, and provider traits.
+//!
+//! This crate is dependency-light and contains no I/O. Storage and CLI
+//! crates depend on it; it depends on neither.
+```
+
+- [ ] **Step 9: Write `clarion-storage`'s `Cargo.toml` and `lib.rs`**
+
+Write `/home/john/clarion/crates/clarion-storage/Cargo.toml`:
+
+```toml
+[package]
+name = "clarion-storage"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+rust-version.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+clarion-core = { path = "../clarion-core" }
+deadpool-sqlite.workspace = true
+rusqlite.workspace = true
+serde_json.workspace = true
+thiserror.workspace = true
+tokio.workspace = true
+tracing.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
+tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "test-util"] }
+```
+
+Write `/home/john/clarion/crates/clarion-storage/src/lib.rs`:
+
+```rust
+//! clarion-storage — SQLite layer, writer-actor, reader pool.
+//!
+//! All mutations route through the writer actor (a single `tokio::task`
+//! owning the sole write `rusqlite::Connection`). Readers come from a
+//! `deadpool-sqlite` pool. See ADR-011.
+```
+
+- [ ] **Step 10: Write `clarion-cli`'s `Cargo.toml` and `main.rs`**
+
+Write `/home/john/clarion/crates/clarion-cli/Cargo.toml`:
+
+```toml
+[package]
+name = "clarion-cli"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+rust-version.workspace = true
+
+[lints]
+workspace = true
+
+[[bin]]
+name = "clarion"
+path = "src/main.rs"
+
+[dependencies]
+anyhow.workspace = true
+clap.workspace = true
+clarion-core = { path = "../clarion-core" }
+clarion-storage = { path = "../clarion-storage" }
+serde_json.workspace = true
+tokio.workspace = true
+tracing.workspace = true
+tracing-subscriber.workspace = true
+
+[dev-dependencies]
+assert_cmd.workspace = true
+tempfile.workspace = true
+```
+
+Write `/home/john/clarion/crates/clarion-cli/src/main.rs`:
+
+```rust
+//! clarion — command-line entry point.
+//!
+//! Real subcommand implementations land in Tasks 5 and 7. This Task-1
+//! stub exists so the workspace compiles pedantic-clean from day one.
+
+fn main() -> anyhow::Result<()> {
+    eprintln!("clarion: unimplemented (Sprint 1 WP1 scaffold — Task 1)");
+    std::process::exit(2);
+}
+```
+
+- [ ] **Step 11: Install required dev tooling (one-time, local only)**
+
+If `cargo nextest` and `cargo deny` are not yet installed on the dev machine:
+
+```bash
+cargo install cargo-nextest --locked
+cargo install cargo-deny --locked
+```
+
+CI installs these via `taiki-e/install-action`; local execution needs them once per machine.
+
+- [ ] **Step 12: Verify every ADR-023 gate passes locally**
+
+Run each in sequence. Every one must exit zero before committing:
+
+```bash
+cd /home/john/clarion && cargo build --workspace
+cd /home/john/clarion && cargo fmt --all -- --check
+cd /home/john/clarion && cargo clippy --workspace --all-targets --all-features -- -D warnings
+cd /home/john/clarion && cargo nextest run --workspace --all-features
+cd /home/john/clarion && cargo doc --workspace --no-deps --all-features
+cd /home/john/clarion && cargo deny check
+```
+
+Expected: all six commands exit 0. `cargo nextest run` reports "no tests to run" at this stage (Task 2 lands the first tests). `cargo deny check` may warn about `multiple-versions` if two transitive deps resolve different versions of the same crate — warnings are fine; only errors block.
+
+If `cargo clippy` fires any pedantic warning (from `clippy::pedantic = "warn"` × `-D warnings` escalation), fix it in the offending file. Common Task-1 cases: missing `# Errors` doc on public fn (pragmatic-allowed, should not fire), `eprintln!` over `tracing` (a stub in `main.rs` — leave it), or unused imports (fix).
+
+- [ ] **Step 13: Commit**
+
+```bash
+cd /home/john/clarion && git add Cargo.toml rust-toolchain.toml rustfmt.toml clippy.toml deny.toml .github/ .gitignore crates/ && git commit -m "$(cat <<'EOF'
+feat(wp1): workspace skeleton + ADR-023 tooling baseline
+
+Cargo workspace with clarion-core, clarion-storage, clarion-cli members,
+edition 2024, resolver 3, workspace [lints] block with clippy::pedantic =
+"warn" + unsafe_code = "forbid" (ADR-023). Every member crate declares
+lints.workspace = true so a later-added crate cannot drift off the floor.
+
+ADR-011 dep stack pinned at workspace level: rusqlite (bundled),
+deadpool-sqlite, tokio, thiserror, clap, tracing. rust-toolchain.toml pins
+stable with clippy + rustfmt + llvm-tools-preview components.
+
+rustfmt.toml, clippy.toml, deny.toml configured per ADR-023. GitHub
+Actions workflow runs fmt-check + pedantic clippy + cargo-nextest +
+cargo-doc + cargo-deny on every PR. python-plugin job arrives with WP3
+Task 1.
+
+Resolves UQ-WP1-09 (reopened from the original "edition 2021, fine to
+document and move on" framing).
+EOF
+)"
+```
+
+---
+
+## Task 2: Entity-ID assembler (L2)
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-core/src/entity_id.rs`
+- Modify: `/home/john/clarion/crates/clarion-core/src/lib.rs`
+
+- [ ] **Step 1: Write the failing unit tests**
+
+Write `/home/john/clarion/crates/clarion-core/src/entity_id.rs`:
+
+```rust
+//! Entity-ID assembler.
+//!
+//! Per ADR-003 + ADR-022, every Clarion entity has a stable 3-segment ID:
+//! `{plugin_id}:{kind}:{canonical_qualified_name}`.
+//!
+//! - `plugin_id` and `kind` must match the grammar `[a-z][a-z0-9_]*`.
+//! - `canonical_qualified_name` is opaque to this assembler: its internal
+//!   shape is the emitting plugin's concern (dotted qualnames for the
+//!   Python plugin; content-addressed for core-minted file entities).
+//! - No segment may contain a literal `:` — the separator is reserved.
+//!   ADR-022's grammar precludes it in `plugin_id`/`kind`; `canonical_qualified_name`
+//!   is checked at assembly time (UQ-WP1-07).
+
+use std::fmt;
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct EntityId(String);
+
+impl EntityId {
+    pub fn as_str(&self) -> &str {
+        &self.0
+    }
+}
+
+impl fmt::Display for EntityId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(&self.0)
+    }
+}
+
+#[derive(Debug, Error, PartialEq, Eq)]
+pub enum EntityIdError {
+    #[error("segment {field} empty")]
+    EmptySegment { field: &'static str },
+
+    #[error("segment {field} violates ADR-022 grammar [a-z][a-z0-9_]*: {value:?}")]
+    GrammarViolation { field: &'static str, value: String },
+
+    #[error("segment {field} contains reserved ':' separator: {value:?}")]
+    SegmentContainsColon { field: &'static str, value: String },
+}
+
+/// Assemble an [`EntityId`] from its three segments.
+///
+/// `plugin_id` and `kind` are validated against the ADR-022 grammar.
+/// `canonical_qualified_name` is opaque but may not contain `:`.
+pub fn entity_id(
+    plugin_id: &str,
+    kind: &str,
+    canonical_qualified_name: &str,
+) -> Result {
+    validate_grammar("plugin_id", plugin_id)?;
+    validate_grammar("kind", kind)?;
+    validate_no_colon("canonical_qualified_name", canonical_qualified_name)?;
+    if canonical_qualified_name.is_empty() {
+        return Err(EntityIdError::EmptySegment {
+            field: "canonical_qualified_name",
+        });
+    }
+    Ok(EntityId(format!(
+        "{plugin_id}:{kind}:{canonical_qualified_name}"
+    )))
+}
+
+fn validate_grammar(field: &'static str, value: &str) -> Result<(), EntityIdError> {
+    if value.is_empty() {
+        return Err(EntityIdError::EmptySegment { field });
+    }
+    validate_no_colon(field, value)?;
+    let mut chars = value.chars();
+    let first = chars.next().expect("non-empty checked above");
+    if !first.is_ascii_lowercase() {
+        return Err(EntityIdError::GrammarViolation {
+            field,
+            value: value.to_owned(),
+        });
+    }
+    for c in chars {
+        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
+            return Err(EntityIdError::GrammarViolation {
+                field,
+                value: value.to_owned(),
+            });
+        }
+    }
+    Ok(())
+}
+
+fn validate_no_colon(field: &'static str, value: &str) -> Result<(), EntityIdError> {
+    if value.contains(':') {
+        return Err(EntityIdError::SegmentContainsColon {
+            field,
+            value: value.to_owned(),
+        });
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn module_level_function() {
+        let id = entity_id("python", "function", "demo.hello").unwrap();
+        assert_eq!(id.as_str(), "python:function:demo.hello");
+    }
+
+    #[test]
+    fn class_method() {
+        let id = entity_id("python", "function", "demo.Foo.bar").unwrap();
+        assert_eq!(id.as_str(), "python:function:demo.Foo.bar");
+    }
+
+    #[test]
+    fn nested_function_uses_python_locals_marker() {
+        let id = entity_id("python", "function", "demo.outer..inner").unwrap();
+        assert_eq!(id.as_str(), "python:function:demo.outer..inner");
+    }
+
+    #[test]
+    fn core_reserved_file_kind() {
+        // The file-entity canonical_qualified_name shape is core-file-discovery's
+        // concern (per detailed-design.md §2:229). Sprint 1 only tests the
+        // assembler's concatenation; `src/demo.py` is a stand-in.
+        let id = entity_id("core", "file", "src/demo.py").unwrap();
+        assert_eq!(id.as_str(), "core:file:src/demo.py");
+    }
+
+    #[test]
+    fn core_reserved_subsystem_kind() {
+        let id = entity_id("core", "subsystem", "a1b2c3d4").unwrap();
+        assert_eq!(id.as_str(), "core:subsystem:a1b2c3d4");
+    }
+
+    #[test]
+    fn rejects_empty_plugin_id() {
+        assert_eq!(
+            entity_id("", "function", "demo.hello"),
+            Err(EntityIdError::EmptySegment { field: "plugin_id" }),
+        );
+    }
+
+    #[test]
+    fn rejects_empty_kind() {
+        assert_eq!(
+            entity_id("python", "", "demo.hello"),
+            Err(EntityIdError::EmptySegment { field: "kind" }),
+        );
+    }
+
+    #[test]
+    fn rejects_empty_qualified_name() {
+        assert_eq!(
+            entity_id("python", "function", ""),
+            Err(EntityIdError::EmptySegment {
+                field: "canonical_qualified_name",
+            }),
+        );
+    }
+
+    #[test]
+    fn rejects_uppercase_plugin_id() {
+        assert!(matches!(
+            entity_id("Python", "function", "demo.hello"),
+            Err(EntityIdError::GrammarViolation { field: "plugin_id", .. })
+        ));
+    }
+
+    #[test]
+    fn rejects_digit_prefixed_kind() {
+        assert!(matches!(
+            entity_id("python", "1function", "demo.hello"),
+            Err(EntityIdError::GrammarViolation { field: "kind", .. })
+        ));
+    }
+
+    #[test]
+    fn rejects_hyphen_in_kind() {
+        assert!(matches!(
+            entity_id("python", "func-tion", "demo.hello"),
+            Err(EntityIdError::GrammarViolation { field: "kind", .. })
+        ));
+    }
+
+    #[test]
+    fn rejects_colon_in_qualified_name() {
+        assert!(matches!(
+            entity_id("python", "function", "demo:hello"),
+            Err(EntityIdError::SegmentContainsColon { field: "canonical_qualified_name", .. })
+        ));
+    }
+
+    #[test]
+    fn rejects_colon_in_plugin_id() {
+        // Defence in depth: grammar check rejects this, but the colon
+        // check fires first and produces a more descriptive error.
+        let err = entity_id("py:thon", "function", "demo.hello").unwrap_err();
+        assert!(matches!(
+            err,
+            EntityIdError::SegmentContainsColon { field: "plugin_id", .. }
+        ));
+    }
+
+    #[test]
+    fn entity_id_serialises_as_string() {
+        let id = entity_id("python", "function", "demo.hello").unwrap();
+        let json = serde_json::to_string(&id).unwrap();
+        assert_eq!(json, "\"python:function:demo.hello\"");
+    }
+}
+```
+
+Modify `/home/john/clarion/crates/clarion-core/src/lib.rs` to:
+
+```rust
+//! clarion-core — domain types, identifiers, and provider traits.
+//!
+//! This crate is dependency-light and contains no I/O. Storage and CLI
+//! crates depend on it; it depends on neither.
+
+pub mod entity_id;
+
+pub use entity_id::{entity_id, EntityId, EntityIdError};
+```
+
+- [ ] **Step 2: Run the tests and confirm they pass**
+
+The tests above compile and drive the implementation at the same time (the implementation is written alongside, not separately). Run:
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-core -E 'test(entity_id)'
+```
+
+Expected: all 13 tests pass. If any test fails, the implementation above has a bug — fix the code, not the test.
+
+- [ ] **Step 3: Confirm no clippy warnings**
+
+```bash
+cd /home/john/clarion && cargo clippy -p clarion-core --all-targets -- -D warnings
+```
+
+Expected: no warnings. If clippy complains about unused imports or dead code, address before committing.
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-core/ && git commit -m "$(cat <<'EOF'
+feat(wp1): L2 entity-ID assembler per ADR-003 + ADR-022
+
+entity_id() assembles {plugin_id}:{kind}:{canonical_qualified_name} with
+ADR-022 grammar enforcement on plugin_id and kind. Rejects empty segments
+and any segment containing ':' (UQ-WP1-07 resolution).
+
+13 unit tests cover positive grammar cases (module fn, class method,
+nested fn, core-reserved file/subsystem), empty-segment rejections,
+grammar violations, and colon-in-segment detection.
+
+canonical_qualified_name is validated for empty + colon only; its internal
+shape is the emitting plugin's concern per ADR-022.
+EOF
+)"
+```
+
+---
+
+## Task 3: Schema migration file (L1)
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-storage/migrations/0001_initial_schema.sql`
+- Create: `/home/john/clarion/crates/clarion-storage/src/error.rs`
+- Create: `/home/john/clarion/crates/clarion-storage/src/schema.rs`
+- Create: `/home/john/clarion/crates/clarion-storage/src/pragma.rs`
+- Modify: `/home/john/clarion/crates/clarion-storage/src/lib.rs`
+- Create: `/home/john/clarion/crates/clarion-storage/tests/schema_apply.rs`
+
+- [ ] **Step 1: Write the migration SQL**
+
+Write `/home/john/clarion/crates/clarion-storage/migrations/0001_initial_schema.sql`. The SQL below is transcribed directly from `detailed-design.md §3:593-755` plus the migration-framework `schema_migrations` meta table. Do not summarise, abbreviate, or drop anything — the full shape is load-bearing per the L1 lock-in.
+
+```sql
+-- ============================================================================
+-- Clarion migration 0001 — initial schema.
+--
+-- Source: docs/clarion/v0.1/detailed-design.md §3 (Storage Implementation).
+-- Sprint 1 walking skeleton writes only to `entities` and `runs`, but every
+-- table, FTS5 virtual table, trigger, generated column, index, and view
+-- is created here so the full shape is frozen at L1-lock time. See ADR-011
+-- for the writer-actor + per-N-files transaction model this schema supports.
+-- ============================================================================
+
+BEGIN;
+
+-- Meta: migration tracking. Not in detailed-design §3 — it's the runner's own
+-- bookkeeping table. Applied migrations append a row here; re-runs are no-ops.
+CREATE TABLE schema_migrations (
+    version     INTEGER PRIMARY KEY,
+    name        TEXT NOT NULL,
+    applied_at  TEXT NOT NULL
+);
+
+-- Entities
+CREATE TABLE entities (
+    id                 TEXT PRIMARY KEY,
+    plugin_id          TEXT NOT NULL,
+    kind               TEXT NOT NULL,
+    name               TEXT NOT NULL,
+    short_name         TEXT NOT NULL,
+    parent_id          TEXT REFERENCES entities(id),
+    source_file_id     TEXT REFERENCES entities(id),
+    source_byte_start  INTEGER,
+    source_byte_end    INTEGER,
+    source_line_start  INTEGER,
+    source_line_end    INTEGER,
+    properties         TEXT NOT NULL,
+    content_hash       TEXT,
+    summary            TEXT,
+    wardline           TEXT,
+    first_seen_commit  TEXT,
+    last_seen_commit   TEXT,
+    created_at         TEXT NOT NULL,
+    updated_at         TEXT NOT NULL
+);
+CREATE INDEX ix_entities_last_seen_commit ON entities(last_seen_commit);
+CREATE INDEX ix_entities_kind              ON entities(kind);
+CREATE INDEX ix_entities_plugin_kind       ON entities(plugin_id, kind);
+CREATE INDEX ix_entities_parent            ON entities(parent_id);
+CREATE INDEX ix_entities_source_file       ON entities(source_file_id);
+CREATE INDEX ix_entities_content_hash      ON entities(content_hash);
+
+-- Tags (denormalised)
+CREATE TABLE entity_tags (
+    entity_id  TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
+    tag        TEXT NOT NULL,
+    PRIMARY KEY (entity_id, tag)
+);
+CREATE INDEX ix_entity_tags_tag ON entity_tags(tag);
+
+-- Edges. Deduped by (kind, from_id, to_id); see detailed-design.md §3 note.
+CREATE TABLE edges (
+    id                 TEXT PRIMARY KEY,
+    kind               TEXT NOT NULL,
+    from_id            TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
+    to_id              TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
+    properties         TEXT,
+    source_file_id     TEXT REFERENCES entities(id),
+    source_byte_start  INTEGER,
+    source_byte_end    INTEGER,
+    UNIQUE (kind, from_id, to_id)
+);
+CREATE INDEX ix_edges_from_kind ON edges(from_id, kind);
+CREATE INDEX ix_edges_to_kind   ON edges(to_id,   kind);
+CREATE INDEX ix_edges_kind      ON edges(kind);
+
+-- Findings
+CREATE TABLE findings (
+    id                  TEXT PRIMARY KEY,
+    tool                TEXT NOT NULL,
+    tool_version        TEXT NOT NULL,
+    run_id              TEXT NOT NULL,
+    rule_id             TEXT NOT NULL,
+    kind                TEXT NOT NULL,
+    severity            TEXT NOT NULL,
+    confidence          REAL,
+    confidence_basis    TEXT,
+    entity_id           TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
+    related_entities    TEXT NOT NULL,
+    message             TEXT NOT NULL,
+    evidence            TEXT NOT NULL,
+    properties          TEXT NOT NULL,
+    supports            TEXT NOT NULL,
+    supported_by        TEXT NOT NULL,
+    status              TEXT NOT NULL,
+    suppression_reason  TEXT,
+    filigree_issue_id   TEXT,
+    created_at          TEXT NOT NULL,
+    updated_at          TEXT NOT NULL
+);
+CREATE INDEX ix_findings_entity    ON findings(entity_id);
+CREATE INDEX ix_findings_rule      ON findings(rule_id);
+CREATE INDEX ix_findings_tool_rule ON findings(tool, rule_id);
+CREATE INDEX ix_findings_run       ON findings(run_id);
+CREATE INDEX ix_findings_status    ON findings(status);
+
+-- Summary cache
+CREATE TABLE summary_cache (
+    entity_id             TEXT NOT NULL,
+    content_hash          TEXT NOT NULL,
+    prompt_template_id    TEXT NOT NULL,
+    model_tier            TEXT NOT NULL,
+    guidance_fingerprint  TEXT NOT NULL,
+    summary_json          TEXT NOT NULL,
+    cost_usd              REAL NOT NULL,
+    tokens_input          INTEGER NOT NULL,
+    tokens_output         INTEGER NOT NULL,
+    created_at            TEXT NOT NULL,
+    PRIMARY KEY (entity_id, content_hash, prompt_template_id, model_tier, guidance_fingerprint)
+);
+
+-- Runs (provenance). Sprint 1 writes started_at/completed_at/config/stats/status;
+-- WP2 will populate plugin-invocation fields inside `config` JSON (per UQ-WP1-05).
+CREATE TABLE runs (
+    id            TEXT PRIMARY KEY,
+    started_at    TEXT NOT NULL,
+    completed_at  TEXT,
+    config        TEXT NOT NULL,
+    stats         TEXT NOT NULL,
+    status        TEXT NOT NULL
+);
+
+-- FTS5 for text search
+CREATE VIRTUAL TABLE entity_fts USING fts5(
+    entity_id UNINDEXED,
+    name,
+    short_name,
+    summary_text,
+    content_text,
+    tokenize = 'porter unicode61'
+);
+
+-- FTS5 triggers keep entity_fts synchronised with entities.
+CREATE TRIGGER entities_ai AFTER INSERT ON entities BEGIN
+    INSERT INTO entity_fts (entity_id, name, short_name, summary_text, content_text)
+    VALUES (
+        new.id,
+        new.name,
+        new.short_name,
+        COALESCE(json_extract(new.summary, '$.briefing.purpose'), ''),
+        ''
+    );
+END;
+CREATE TRIGGER entities_au AFTER UPDATE ON entities BEGIN
+    UPDATE entity_fts
+    SET name         = new.name,
+        short_name   = new.short_name,
+        summary_text = COALESCE(json_extract(new.summary, '$.briefing.purpose'), '')
+    WHERE entity_id = new.id;
+END;
+CREATE TRIGGER entities_ad AFTER DELETE ON entities BEGIN
+    DELETE FROM entity_fts WHERE entity_id = old.id;
+END;
+
+-- Generated columns + partial indexes for hot JSON properties.
+ALTER TABLE entities ADD COLUMN priority TEXT
+    GENERATED ALWAYS AS (json_extract(properties, '$.priority')) VIRTUAL;
+CREATE INDEX ix_entities_priority ON entities(priority) WHERE priority IS NOT NULL;
+
+ALTER TABLE entities ADD COLUMN git_churn_count INTEGER
+    GENERATED ALWAYS AS (json_extract(properties, '$.git_churn_count')) VIRTUAL;
+CREATE INDEX ix_entities_churn ON entities(git_churn_count) WHERE git_churn_count IS NOT NULL;
+
+-- View for guidance resolver. Note: this view references an `entity_tags`
+-- join indirectly via the `tags` column — but detailed-design §3 writes
+-- `tags` directly from entities, which does not exist as a column. To
+-- honour the detailed-design literally the view joins through entity_tags
+-- using a subquery that aggregates.
+CREATE VIEW guidance_sheets AS
+SELECT
+    e.id,
+    e.name,
+    json_extract(e.properties, '$.priority')             AS priority,
+    json_extract(e.properties, '$.scope.query_types')    AS query_types,
+    json_extract(e.properties, '$.scope.token_budget')   AS token_budget,
+    json_extract(e.properties, '$.match_rules')          AS match_rules,
+    json_extract(e.properties, '$.content')              AS content,
+    json_extract(e.properties, '$.expires')              AS expires,
+    (
+        SELECT json_group_array(tag)
+        FROM entity_tags
+        WHERE entity_id = e.id
+    )                                                     AS tags
+FROM entities e
+WHERE e.kind = 'guidance';
+
+-- Record the migration.
+INSERT INTO schema_migrations (version, name, applied_at)
+VALUES (1, '0001_initial_schema', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
+
+COMMIT;
+```
+
+**Note on the `guidance_sheets` view**: `detailed-design.md §3:746-755` references a bare `tags` column on `entities`, but the schema has `tags` normalised into `entity_tags`. The view above aggregates `entity_tags.tag` via a correlated subquery into a JSON array — this is the faithful join shape and produces the same row shape the design doc implies. If the detailed-design is updated post-Sprint-1 to match this, that's the design-doc's bug and not ours.
+
+- [ ] **Step 2: Write the `StorageError` type**
+
+Write `/home/john/clarion/crates/clarion-storage/src/error.rs`:
+
+```rust
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum StorageError {
+    #[error("sqlite error: {0}")]
+    Sqlite(#[from] rusqlite::Error),
+
+    #[error("connection-pool error: {0}")]
+    Pool(String),
+
+    #[error("migration {version} failed: {source}")]
+    Migration {
+        version: u32,
+        #[source]
+        source: rusqlite::Error,
+    },
+
+    #[error("io error: {0}")]
+    Io(#[from] std::io::Error),
+
+    #[error("channel closed — writer actor has exited")]
+    WriterGone,
+
+    #[error("writer actor returned no response")]
+    WriterNoResponse,
+}
+
+pub type Result = std::result::Result;
+```
+
+- [ ] **Step 3: Write the PRAGMA application helper**
+
+Write `/home/john/clarion/crates/clarion-storage/src/pragma.rs`:
+
+```rust
+//! PRAGMAs applied at connection open per ADR-011 §SQLite PRAGMAs.
+
+use rusqlite::Connection;
+
+use crate::error::Result;
+
+/// Apply the write-side PRAGMA set: WAL, synchronous=NORMAL, busy_timeout,
+/// wal_autocheckpoint, foreign_keys. Called on the writer's connection once,
+/// immediately after open.
+pub fn apply_write_pragmas(conn: &Connection) -> Result<()> {
+    // `journal_mode = WAL` needs query_row because it returns the new mode.
+    let mode: String =
+        conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
+    debug_assert_eq!(mode.to_ascii_lowercase(), "wal", "WAL not enabled");
+    conn.execute_batch(concat!(
+        "PRAGMA synchronous = NORMAL;",
+        "PRAGMA busy_timeout = 5000;",
+        "PRAGMA wal_autocheckpoint = 1000;",
+        "PRAGMA foreign_keys = ON;",
+    ))?;
+    Ok(())
+}
+
+/// Apply the read-side PRAGMA set: busy_timeout + foreign_keys. Readers do not
+/// set journal_mode (WAL is a database-level mode set by the first writer).
+pub fn apply_read_pragmas(conn: &Connection) -> Result<()> {
+    conn.execute_batch(concat!(
+        "PRAGMA busy_timeout = 5000;",
+        "PRAGMA foreign_keys = ON;",
+    ))?;
+    Ok(())
+}
+```
+
+- [ ] **Step 4: Write the schema migration runner**
+
+Write `/home/john/clarion/crates/clarion-storage/src/schema.rs`:
+
+```rust
+//! Schema migration runner.
+//!
+//! Migrations are embedded at compile time via `include_str!`. On apply, each
+//! is run inside its own transaction if not already recorded in
+//! `schema_migrations`. Running twice is a no-op.
+
+use rusqlite::{params, Connection};
+
+use crate::error::{Result, StorageError};
+
+struct Migration {
+    version: u32,
+    name: &'static str,
+    sql: &'static str,
+}
+
+const MIGRATIONS: &[Migration] = &[Migration {
+    version: 1,
+    name: "0001_initial_schema",
+    sql: include_str!("../migrations/0001_initial_schema.sql"),
+}];
+
+/// Apply every migration not already recorded in `schema_migrations`.
+///
+/// The first migration creates the `schema_migrations` table itself, so the
+/// initial `SELECT` is tolerant of its absence.
+pub fn apply_migrations(conn: &mut Connection) -> Result<()> {
+    let applied = read_applied_versions(conn)?;
+    for m in MIGRATIONS {
+        if applied.contains(&m.version) {
+            tracing::debug!(version = m.version, "migration already applied");
+            continue;
+        }
+        apply_one(conn, m)?;
+    }
+    Ok(())
+}
+
+fn read_applied_versions(conn: &Connection) -> Result> {
+    // The first migration creates schema_migrations; tolerate its absence.
+    let table_exists: Option = conn
+        .query_row(
+            "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
+            [],
+            |row| row.get(0),
+        )
+        .ok();
+    if table_exists.is_none() {
+        return Ok(Vec::new());
+    }
+    let mut stmt = conn.prepare("SELECT version FROM schema_migrations ORDER BY version")?;
+    let rows = stmt
+        .query_map([], |row| row.get::<_, i64>(0))?
+        .map(|r| r.map(|v| v as u32));
+    let mut out = Vec::new();
+    for r in rows {
+        out.push(r?);
+    }
+    Ok(out)
+}
+
+fn apply_one(conn: &mut Connection, m: &Migration) -> Result<()> {
+    tracing::info!(version = m.version, name = m.name, "applying migration");
+    // The migration file wraps its own BEGIN/COMMIT; execute_batch tolerates
+    // multiple statements including the explicit transaction wrapper.
+    conn.execute_batch(m.sql)
+        .map_err(|source| StorageError::Migration {
+            version: m.version,
+            source,
+        })?;
+    // Defence in depth: some migrations may forget to insert into
+    // schema_migrations. Upsert to guarantee idempotency.
+    conn.execute(
+        "INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) \
+         VALUES (?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
+        params![m.version as i64, m.name],
+    )?;
+    Ok(())
+}
+
+/// Count of applied migrations (for tests + install).
+pub fn applied_count(conn: &Connection) -> Result {
+    let n: i64 = conn
+        .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| row.get(0))
+        .unwrap_or(0);
+    Ok(n as u32)
+}
+```
+
+- [ ] **Step 5: Wire the modules into `lib.rs`**
+
+Replace `/home/john/clarion/crates/clarion-storage/src/lib.rs` with:
+
+```rust
+//! clarion-storage — SQLite layer, writer-actor, reader pool.
+//!
+//! All mutations route through [`writer::Writer`] (a single `tokio::task`
+//! owning the sole write `rusqlite::Connection`). Readers come from a
+//! `deadpool-sqlite` pool. See ADR-011.
+
+pub mod error;
+pub mod pragma;
+pub mod schema;
+
+pub use error::{Result, StorageError};
+```
+
+- [ ] **Step 6: Write the integration test**
+
+Write `/home/john/clarion/crates/clarion-storage/tests/schema_apply.rs`:
+
+```rust
+//! Schema-apply integration tests.
+//!
+//! Verifies that migration 0001 produces every table, index, trigger,
+//! generated column, and view from detailed-design.md §3, and that
+//! applying migrations a second time is a no-op.
+
+use rusqlite::{params, Connection};
+
+use clarion_storage::{pragma, schema};
+
+fn open_fresh(tempdir: &tempfile::TempDir) -> Connection {
+    let path = tempdir.path().join("clarion.db");
+    let mut conn = Connection::open(&path).expect("open");
+    pragma::apply_write_pragmas(&conn).expect("pragmas");
+    schema::apply_migrations(&mut conn).expect("apply migrations");
+    conn
+}
+
+fn table_names(conn: &Connection) -> Vec {
+    let mut stmt = conn
+        .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
+        .unwrap();
+    stmt.query_map([], |row| row.get::<_, String>(0))
+        .unwrap()
+        .map(Result::unwrap)
+        .collect()
+}
+
+fn trigger_names(conn: &Connection) -> Vec {
+    let mut stmt = conn
+        .prepare("SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name")
+        .unwrap();
+    stmt.query_map([], |row| row.get::<_, String>(0))
+        .unwrap()
+        .map(Result::unwrap)
+        .collect()
+}
+
+fn view_names(conn: &Connection) -> Vec {
+    let mut stmt = conn
+        .prepare("SELECT name FROM sqlite_master WHERE type='view' ORDER BY name")
+        .unwrap();
+    stmt.query_map([], |row| row.get::<_, String>(0))
+        .unwrap()
+        .map(Result::unwrap)
+        .collect()
+}
+
+fn index_names(conn: &Connection) -> Vec {
+    let mut stmt = conn
+        .prepare(
+            "SELECT name FROM sqlite_master \
+             WHERE type='index' AND name NOT LIKE 'sqlite_%' ORDER BY name",
+        )
+        .unwrap();
+    stmt.query_map([], |row| row.get::<_, String>(0))
+        .unwrap()
+        .map(Result::unwrap)
+        .collect()
+}
+
+#[test]
+fn migration_0001_creates_every_expected_table() {
+    let tempdir = tempfile::tempdir().unwrap();
+    let conn = open_fresh(&tempdir);
+    let tables = table_names(&conn);
+    for expected in &[
+        "edges",
+        "entities",
+        "entity_tags",
+        "findings",
+        "runs",
+        "schema_migrations",
+        "summary_cache",
+    ] {
+        assert!(
+            tables.iter().any(|t| t == expected),
+            "missing table {expected} in {tables:?}"
+        );
+    }
+}
+
+#[test]
+fn migration_0001_creates_entity_fts_virtual_table() {
+    let tempdir = tempfile::tempdir().unwrap();
+    let conn = open_fresh(&tempdir);
+    // Virtual tables appear in sqlite_master as type='table' with sql starting "CREATE VIRTUAL".
+    let sql: String = conn
+        .query_row(
+            "SELECT sql FROM sqlite_master WHERE name='entity_fts'",
+            [],
+            |row| row.get(0),
+        )
+        .unwrap();
+    assert!(sql.contains("CREATE VIRTUAL TABLE"), "sql was: {sql}");
+    // Queryable (shape check only — empty result is fine).
+    conn.execute_batch("SELECT entity_id, name FROM entity_fts LIMIT 0")
+        .expect("entity_fts queryable");
+}
+
+#[test]
+fn migration_0001_creates_all_three_fts_triggers() {
+    let tempdir = tempfile::tempdir().unwrap();
+    let conn = open_fresh(&tempdir);
+    let triggers = trigger_names(&conn);
+    for expected in &["entities_ad", "entities_ai", "entities_au"] {
+        assert!(
+            triggers.iter().any(|t| t == expected),
+            "missing trigger {expected} in {triggers:?}"
+        );
+    }
+}
+
+#[test]
+fn migration_0001_creates_guidance_sheets_view() {
+    let tempdir = tempfile::tempdir().unwrap();
+    let conn = open_fresh(&tempdir);
+    let views = view_names(&conn);
+    assert!(views.iter().any(|v| v == "guidance_sheets"), "views: {views:?}");
+    conn.execute_batch("SELECT id, name, priority FROM guidance_sheets LIMIT 0")
+        .expect("guidance_sheets queryable");
+}
+
+#[test]
+fn migration_0001_creates_partial_indexes() {
+    let tempdir = tempfile::tempdir().unwrap();
+    let conn = open_fresh(&tempdir);
+    let indexes = index_names(&conn);
+    for expected in &["ix_entities_churn", "ix_entities_priority"] {
+        assert!(
+            indexes.iter().any(|i| i == expected),
+            "missing index {expected} in {indexes:?}"
+        );
+    }
+}
+
+#[test]
+fn entity_generated_columns_extract_from_properties_json() {
+    let tempdir = tempfile::tempdir().unwrap();
+    let conn = open_fresh(&tempdir);
+    let props = r#"{"priority": "P1", "git_churn_count": 42}"#;
+    conn.execute(
+        "INSERT INTO entities (id, plugin_id, kind, name, short_name, properties, \
+         created_at, updated_at) \
+         VALUES (?1, ?2, ?3, ?4, ?5, ?6, \
+         strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
+        params!["python:function:demo.f", "python", "function", "demo.f", "f", props],
+    )
+    .unwrap();
+    let (priority, churn): (Option, Option) = conn
+        .query_row(
+            "SELECT priority, git_churn_count FROM entities WHERE id = ?1",
+            params!["python:function:demo.f"],
+            |row| Ok((row.get(0)?, row.get(1)?)),
+        )
+        .unwrap();
+    assert_eq!(priority.as_deref(), Some("P1"));
+    assert_eq!(churn, Some(42));
+}
+
+#[test]
+fn migrations_are_idempotent() {
+    let tempdir = tempfile::tempdir().unwrap();
+    let mut conn = open_fresh(&tempdir);
+    // Second apply on the same connection.
+    schema::apply_migrations(&mut conn).expect("second apply should be a no-op");
+    assert_eq!(schema::applied_count(&conn).unwrap(), 1);
+    let tables_after = table_names(&conn);
+    assert!(tables_after.contains(&"entities".to_owned()));
+}
+
+#[test]
+fn schema_migrations_records_one_row() {
+    let tempdir = tempfile::tempdir().unwrap();
+    let conn = open_fresh(&tempdir);
+    let count: i64 = conn
+        .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| row.get(0))
+        .unwrap();
+    assert_eq!(count, 1);
+    let name: String = conn
+        .query_row(
+            "SELECT name FROM schema_migrations WHERE version = 1",
+            [],
+            |row| row.get(0),
+        )
+        .unwrap();
+    assert_eq!(name, "0001_initial_schema");
+}
+```
+
+- [ ] **Step 7: Run the tests**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-storage --test schema_apply
+```
+
+Expected: 7 tests pass. If any fail, the migration SQL has a bug — fix `0001_initial_schema.sql`, not the tests.
+
+**Checkpoint** (per the plan review): if `cargo nextest run -p clarion-storage --test schema_apply` isn't green by end of day 2 of WP1 execution, pause and reassess before proceeding to Task 4. A half-locked schema is worse than a one-day slip.
+
+- [ ] **Step 8: Clippy clean**
+
+```bash
+cd /home/john/clarion && cargo clippy -p clarion-storage --all-targets -- -D warnings
+```
+
+Expected: no warnings.
+
+- [ ] **Step 9: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-storage/ && git commit -m "$(cat <<'EOF'
+feat(wp1): L1 SQLite schema migration framework
+
+Migration 0001 transcribes the full detailed-design.md §3 schema: tables
+(entities, entity_tags, edges, findings, summary_cache, runs,
+schema_migrations), entity_fts FTS5 virtual table, three FTS triggers
+(_ai/_au/_ad), priority + git_churn_count generated columns with partial
+indexes, and the guidance_sheets view (aggregating tags via correlated
+subquery against entity_tags — reconciles §3 shape with the normalised
+tag storage).
+
+schema::apply_migrations() reads embedded SQL via include_str!, tolerates
+re-runs (UQ idempotency), and records each apply in schema_migrations.
+pragma::apply_write_pragmas() + apply_read_pragmas() centralise ADR-011
+connection-open invariants. StorageError wraps rusqlite::Error via
+thiserror (UQ-WP1-06 resolution).
+
+7 integration tests in schema_apply.rs cover table/trigger/view presence,
+FTS queryability, generated-column round-trip, and idempotency.
+EOF
+)"
+```
+
+---
+
+## Task 4: Reader pool
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-storage/src/reader.rs`
+- Modify: `/home/john/clarion/crates/clarion-storage/src/lib.rs`
+- Create: `/home/john/clarion/crates/clarion-storage/tests/reader_pool.rs`
+
+- [ ] **Step 1: Write the reader pool wrapper**
+
+Write `/home/john/clarion/crates/clarion-storage/src/reader.rs`:
+
+```rust
+//! Read-only connection pool wrapping `deadpool-sqlite` per ADR-011.
+//!
+//! Readers take a connection from the pool, run a query, and drop it. The
+//! pool caps concurrent connections (default 16). WAL mode lets readers
+//! see the committed snapshot at the moment they open; writes become
+//! visible only after the next checkpoint or a fresh connection.
+
+use std::path::Path;
+
+use deadpool_sqlite::{Config, Pool, Runtime};
+
+use crate::error::{Result, StorageError};
+use crate::pragma;
+
+pub struct ReaderPool {
+    pool: Pool,
+}
+
+impl ReaderPool {
+    /// Open a pool against an existing SQLite file.
+    ///
+    /// The database file must already exist and already have migrations
+    /// applied — callers should run `schema::apply_migrations` on a write
+    /// connection first.
+    pub fn open(db_path: impl AsRef, max_size: usize) -> Result {
+        let mut cfg = Config::new(db_path.as_ref());
+        cfg.pool = Some(deadpool_sqlite::PoolConfig::new(max_size));
+        let pool = cfg
+            .create_pool(Runtime::Tokio1)
+            .map_err(|e| StorageError::Pool(format!("create_pool: {e}")))?;
+        Ok(Self { pool })
+    }
+
+    /// Acquire a reader and run a blocking closure on it. PRAGMAs are
+    /// applied on every acquisition — it's cheap (a few PRAGMA statements)
+    /// and guarantees busy_timeout + foreign_keys are always on.
+    pub async fn with_reader(&self, f: F) -> Result
+    where
+        F: FnOnce(&rusqlite::Connection) -> Result + Send + 'static,
+        T: Send + 'static,
+    {
+        let obj = self
+            .pool
+            .get()
+            .await
+            .map_err(|e| StorageError::Pool(format!("acquire: {e}")))?;
+        obj.interact(move |conn| -> Result {
+            pragma::apply_read_pragmas(conn)?;
+            f(conn)
+        })
+        .await
+        .map_err(|e| StorageError::Pool(format!("interact: {e}")))?
+    }
+}
+```
+
+- [ ] **Step 2: Export from `lib.rs`**
+
+Modify `/home/john/clarion/crates/clarion-storage/src/lib.rs` to add the new module:
+
+```rust
+//! clarion-storage — SQLite layer, writer-actor, reader pool.
+
+pub mod error;
+pub mod pragma;
+pub mod reader;
+pub mod schema;
+
+pub use error::{Result, StorageError};
+pub use reader::ReaderPool;
+```
+
+- [ ] **Step 3: Write the failing integration test**
+
+Write `/home/john/clarion/crates/clarion-storage/tests/reader_pool.rs`:
+
+```rust
+//! Reader-pool concurrency tests.
+
+use std::sync::Arc;
+
+use rusqlite::Connection;
+
+use clarion_storage::{pragma, schema, ReaderPool};
+
+fn prepared_db(dir: &tempfile::TempDir) -> std::path::PathBuf {
+    let path = dir.path().join("clarion.db");
+    let mut conn = Connection::open(&path).expect("open");
+    pragma::apply_write_pragmas(&conn).expect("write pragmas");
+    schema::apply_migrations(&mut conn).expect("migrate");
+    path
+}
+
+#[tokio::test]
+async fn two_readers_run_concurrently() {
+    let dir = tempfile::tempdir().unwrap();
+    let path = prepared_db(&dir);
+    let pool = Arc::new(ReaderPool::open(&path, 2).expect("pool"));
+
+    let p1 = pool.clone();
+    let p2 = pool.clone();
+    let (a, b) = tokio::join!(
+        p1.with_reader(|conn| {
+            let n: i64 = conn.query_row("SELECT 1", [], |row| row.get(0))?;
+            Ok(n)
+        }),
+        p2.with_reader(|conn| {
+            let n: i64 = conn.query_row("SELECT 2", [], |row| row.get(0))?;
+            Ok(n)
+        })
+    );
+    assert_eq!(a.unwrap(), 1);
+    assert_eq!(b.unwrap(), 2);
+}
+
+#[tokio::test]
+async fn reader_sees_committed_data() {
+    let dir = tempfile::tempdir().unwrap();
+    let path = prepared_db(&dir);
+
+    // Pre-seed an entity via a one-shot blocking connection.
+    {
+        let conn = Connection::open(&path).unwrap();
+        pragma::apply_write_pragmas(&conn).unwrap();
+        conn.execute(
+            "INSERT INTO runs (id, started_at, completed_at, config, stats, status) \
+             VALUES (?1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), NULL, '{}', '{}', 'running')",
+            rusqlite::params!["run-1"],
+        )
+        .unwrap();
+    }
+
+    let pool = ReaderPool::open(&path, 2).expect("pool");
+    let status: String = pool
+        .with_reader(|conn| {
+            let status: String = conn.query_row(
+                "SELECT status FROM runs WHERE id = 'run-1'",
+                [],
+                |row| row.get(0),
+            )?;
+            Ok(status)
+        })
+        .await
+        .unwrap();
+    assert_eq!(status, "running");
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-storage --test reader_pool
+```
+
+Expected: 2 tests pass.
+
+- [ ] **Step 5: Clippy clean**
+
+```bash
+cd /home/john/clarion && cargo clippy -p clarion-storage --all-targets -- -D warnings
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-storage/ && git commit -m "$(cat <<'EOF'
+feat(wp1): reader pool for concurrent read connections
+
+ReaderPool wraps deadpool-sqlite (ADR-011 default max 16, configurable
+per call site). with_reader() acquires a pooled connection, applies the
+read-side PRAGMAs, and runs the caller's closure inside deadpool's
+interact() block so the runtime yields during SQLite I/O.
+
+Integration tests cover concurrent-read throughput (two readers via
+tokio::join!) and committed-snapshot visibility (reader sees a row
+committed on an out-of-band write connection).
+EOF
+)"
+```
+
+---
+
+## Task 5: `clarion install` subcommand
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-cli/src/install.rs`
+- Create: `/home/john/clarion/crates/clarion-cli/src/cli.rs`
+- Modify: `/home/john/clarion/crates/clarion-cli/src/main.rs`
+- Create: `/home/john/clarion/crates/clarion-cli/tests/install.rs`
+- Create: `/home/john/clarion/docs/clarion/adr/ADR-005-clarion-dir-tracking.md`
+- Modify: `/home/john/clarion/docs/clarion/adr/README.md`
+
+- [ ] **Step 1: Write the CLI command enum**
+
+Write `/home/john/clarion/crates/clarion-cli/src/cli.rs`:
+
+```rust
+use std::path::PathBuf;
+
+use clap::{Parser, Subcommand};
+
+#[derive(Parser)]
+#[command(name = "clarion", version, about = "Clarion code-archaeology tool")]
+pub struct Cli {
+    #[command(subcommand)]
+    pub command: Command,
+}
+
+#[derive(Subcommand)]
+pub enum Command {
+    /// Initialise .clarion/ in the current directory.
+    Install {
+        /// Overwrite an existing .clarion/ (not implemented in Sprint 1).
+        #[arg(long)]
+        force: bool,
+
+        /// Directory to install into (default: current directory).
+        #[arg(long, default_value = ".")]
+        path: PathBuf,
+    },
+
+    /// Run an analysis pass. Sprint 1: no plugins are loaded; run status is
+    /// `skipped_no_plugins`. WP2 wires plugin spawning.
+    Analyze {
+        /// Path to analyse (default: current directory).
+        #[arg(default_value = ".")]
+        path: PathBuf,
+    },
+}
+```
+
+- [ ] **Step 2: Write the install command implementation**
+
+Write `/home/john/clarion/crates/clarion-cli/src/install.rs`:
+
+```rust
+//! `clarion install` — initialise .clarion/ in the target directory.
+//!
+//! Creates:
+//! - `.clarion/clarion.db`        (migrated)
+//! - `.clarion/config.json`       (internal state stub)
+//! - `.clarion/.gitignore`        (UQ-WP1-04 rules; ADR-005)
+//! - `/clarion.yaml`        (user-edited config stub at project root
+//!                                  per detailed-design.md §File layout)
+//!
+//! Refuses if `.clarion/` already exists (UQ-WP1-08). `--force` is accepted
+//! by the CLI but currently returns an error — Sprint 1 does not implement
+//! overwrite.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use anyhow::{bail, Context, Result};
+use rusqlite::Connection;
+
+use clarion_storage::{pragma, schema};
+
+const CONFIG_JSON_STUB: &str = r#"{
+    "schema_version": 1,
+    "last_run_id": null
+}
+"#;
+
+const CLARION_YAML_STUB: &str = "# clarion.yaml — user-edited config.\n\
+# Full schema TBD; see docs/clarion/v0.1 design. Sprint 1 walking skeleton\n\
+# ignores most fields. Do not delete this file: later versions will require\n\
+# it for model-tier mappings and analysis knobs.\n\
+version: 1\n";
+
+const GITIGNORE_CONTENTS: &str = "\
+# Clarion .gitignore — ADR-005 tracked-vs-excluded list.
+# Tracked (committed): clarion.db, config.json, .gitignore itself.
+# Excluded (ignored): WAL sidecars, shadow DB, per-run logs, tmp scratch.
+
+# SQLite write-ahead files never belong in the repo.
+*-wal
+*-shm
+*.db-wal
+*.db-shm
+
+# Shadow DB intermediate (ADR-011 --shadow-db).
+*.shadow.db
+*.db.new
+
+# Scratch / temp space.
+tmp/
+
+# Per-run log directories (see detailed-design §File layout). The run dir
+# metadata (config.yaml, stats.json, partial.json) is tracked; only the
+# raw LLM request/response log is excluded.
+logs/
+runs/*/log.jsonl
+";
+
+pub fn run(path: PathBuf, force: bool) -> Result<()> {
+    if force {
+        bail!(
+            "--force is not implemented in Sprint 1. Remove .clarion/ manually \
+             if you need a clean reinit."
+        );
+    }
+
+    let project_root = path.canonicalize().with_context(|| {
+        format!("cannot canonicalise --path {}", path.display())
+    })?;
+    let clarion_dir = project_root.join(".clarion");
+    if clarion_dir.exists() {
+        bail!(
+            ".clarion/ already exists at {}. Delete it (or pass --force when \
+             Sprint 2+ implements overwrite) and try again.",
+            clarion_dir.display()
+        );
+    }
+
+    fs::create_dir_all(&clarion_dir)
+        .with_context(|| format!("mkdir {}", clarion_dir.display()))?;
+
+    let db_path = clarion_dir.join("clarion.db");
+    initialise_db(&db_path).context("initialise clarion.db")?;
+
+    let config_path = clarion_dir.join("config.json");
+    fs::write(&config_path, CONFIG_JSON_STUB)
+        .with_context(|| format!("write {}", config_path.display()))?;
+
+    let gitignore_path = clarion_dir.join(".gitignore");
+    fs::write(&gitignore_path, GITIGNORE_CONTENTS)
+        .with_context(|| format!("write {}", gitignore_path.display()))?;
+
+    let yaml_path = project_root.join("clarion.yaml");
+    if !yaml_path.exists() {
+        fs::write(&yaml_path, CLARION_YAML_STUB)
+            .with_context(|| format!("write {}", yaml_path.display()))?;
+    }
+
+    tracing::info!(
+        clarion_dir = %clarion_dir.display(),
+        "clarion install complete"
+    );
+    println!("Initialised {}", clarion_dir.display());
+    Ok(())
+}
+
+fn initialise_db(path: &Path) -> Result<()> {
+    let mut conn = Connection::open(path)?;
+    pragma::apply_write_pragmas(&conn)?;
+    schema::apply_migrations(&mut conn)?;
+    Ok(())
+}
+```
+
+- [ ] **Step 3: Wire the CLI into `main.rs`**
+
+Replace `/home/john/clarion/crates/clarion-cli/src/main.rs` with:
+
+```rust
+mod cli;
+mod install;
+
+use anyhow::Result;
+use clap::Parser;
+
+fn main() -> Result<()> {
+    init_tracing();
+    let cli = cli::Cli::parse();
+    match cli.command {
+        cli::Command::Install { force, path } => install::run(path, force),
+        cli::Command::Analyze { path: _ } => {
+            // Task 7 implements this. Stubbed so `clarion analyze` is reachable.
+            anyhow::bail!("clarion analyze — unimplemented (landing in Task 7)");
+        }
+    }
+}
+
+fn init_tracing() {
+    use tracing_subscriber::EnvFilter;
+    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
+    tracing_subscriber::fmt().with_env_filter(filter).with_target(false).init();
+}
+```
+
+- [ ] **Step 4: Write the integration tests**
+
+Write `/home/john/clarion/crates/clarion-cli/tests/install.rs`:
+
+```rust
+//! `clarion install` integration tests.
+
+use std::fs;
+
+use assert_cmd::Command;
+use rusqlite::Connection;
+
+fn clarion_bin() -> Command {
+    Command::cargo_bin("clarion").expect("clarion binary")
+}
+
+#[test]
+fn install_creates_clarion_dir_with_expected_contents() {
+    let dir = tempfile::tempdir().unwrap();
+    clarion_bin()
+        .args(["install", "--path"])
+        .arg(dir.path())
+        .assert()
+        .success();
+
+    let clarion = dir.path().join(".clarion");
+    assert!(clarion.join("clarion.db").exists(), "clarion.db missing");
+    assert!(clarion.join("config.json").exists(), "config.json missing");
+    assert!(clarion.join(".gitignore").exists(), ".gitignore missing");
+    assert!(
+        dir.path().join("clarion.yaml").exists(),
+        "clarion.yaml not at project root"
+    );
+
+    let config = fs::read_to_string(clarion.join("config.json")).unwrap();
+    let parsed: serde_json::Value = serde_json::from_str(&config).unwrap();
+    assert_eq!(parsed["schema_version"], 1);
+    assert!(parsed["last_run_id"].is_null());
+
+    let gitignore = fs::read_to_string(clarion.join(".gitignore")).unwrap();
+    for rule in &["*.shadow.db", "tmp/", "logs/", "runs/*/log.jsonl", "*-wal", "*-shm"] {
+        assert!(
+            gitignore.contains(rule),
+            ".gitignore missing rule {rule}: {gitignore}"
+        );
+    }
+}
+
+#[test]
+fn install_applies_migration_0001_exactly_once() {
+    let dir = tempfile::tempdir().unwrap();
+    clarion_bin()
+        .args(["install", "--path"])
+        .arg(dir.path())
+        .assert()
+        .success();
+
+    let conn = Connection::open(dir.path().join(".clarion/clarion.db")).unwrap();
+    let count: i64 = conn
+        .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| row.get(0))
+        .unwrap();
+    assert_eq!(count, 1);
+    let version: i64 = conn
+        .query_row("SELECT version FROM schema_migrations", [], |row| row.get(0))
+        .unwrap();
+    assert_eq!(version, 1);
+}
+
+#[test]
+fn install_refuses_to_overwrite_existing_clarion_dir() {
+    let dir = tempfile::tempdir().unwrap();
+    clarion_bin()
+        .args(["install", "--path"])
+        .arg(dir.path())
+        .assert()
+        .success();
+
+    // Second install must fail with a clear message.
+    let out = clarion_bin()
+        .args(["install", "--path"])
+        .arg(dir.path())
+        .assert()
+        .failure();
+    let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap();
+    assert!(
+        stderr.contains("already exists"),
+        "error did not mention existing dir: {stderr}"
+    );
+    assert!(
+        stderr.contains("--force"),
+        "error did not mention --force escape hatch: {stderr}"
+    );
+}
+
+#[test]
+fn install_force_returns_unimplemented_in_sprint_one() {
+    let dir = tempfile::tempdir().unwrap();
+    let out = clarion_bin()
+        .args(["install", "--force", "--path"])
+        .arg(dir.path())
+        .assert()
+        .failure();
+    let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap();
+    assert!(
+        stderr.contains("not implemented in Sprint 1"),
+        "expected Sprint 1 --force stub message: {stderr}"
+    );
+}
+```
+
+- [ ] **Step 5: Write ADR-005**
+
+Write `/home/john/clarion/docs/clarion/adr/ADR-005-clarion-dir-tracking.md`:
+
+```markdown
+# ADR-005: `.clarion/` Directory Git-Tracking Policy
+
+**Status**: Accepted
+**Date**: 2026-04-18
+**Deciders**: qacona@gmail.com
+**Context**: `clarion install` must write a `.gitignore` inside `.clarion/` that
+separates committed analysis state from volatile per-run artefacts. Sprint 1 WP1
+Task 5 is the authoring trigger; before this ADR, the rules were only proposed
+in `docs/implementation/sprint-1/wp1-scaffold.md §UQ-WP1-04`.
+
+## Summary
+
+`.clarion/clarion.db` and `.clarion/config.json` are committed. WAL sidecars,
+the shadow-DB intermediate, `tmp/`, `logs/`, and per-run raw LLM request/response
+logs (`runs/*/log.jsonl`) are `.gitignore`d. `clarion.yaml` lives at the project
+root and is tracked under the user's existing repo-root `.gitignore`, not under
+`.clarion/.gitignore` (it's a user-edited config, not analysis state).
+
+## Context
+
+`.clarion/` mixes artefact kinds that want different tracking posture:
+
+- **Shared analysis state** (entities, edges, briefings, guidance) — diff-friendly
+  via `clarion db export --textual`; solo-developer and small-team cases benefit
+  from having briefings versioned alongside the code they describe
+  (`detailed-design.md §3 File layout`).
+- **Runtime write-ahead files** (`*-wal`, `*-shm`) — SQLite bookkeeping that is
+  process-local and meaningless on a different machine.
+- **Shadow DB** (`clarion.db.new`, `*.shadow.db`) — ADR-011's `--shadow-db`
+  intermediate; deleted on successful atomic rename, would leak as junk
+  otherwise.
+- **Per-run LLM bodies** (`runs//log.jsonl`) — raw request/response
+  bodies for audit. May contain source excerpts fine to ship to Anthropic
+  but not appropriate to commit to a public repo.
+- **Scratch** (`tmp/`, `logs/`) — volatile by definition.
+
+Without this ADR, `clarion install` has no normative place to look up the rules,
+and every developer's install produces their own variant `.gitignore` by accident.
+
+## Decision
+
+`clarion install` writes `.clarion/.gitignore` with the following contents
+(verbatim — the literal file lives at
+`crates/clarion-cli/src/install.rs` and ships as the v0.1 baseline):
+
+```
+*-wal
+*-shm
+*.db-wal
+*.db-shm
+*.shadow.db
+*.db.new
+tmp/
+logs/
+runs/*/log.jsonl
+```
+
+### Tracked
+
+- `.clarion/clarion.db` — the main analysis store. SQLite diffs poorly; the
+  `clarion db export --textual` + `clarion db merge-helper` pattern (detailed
+  design §3 File layout) handles the team case.
+- `.clarion/config.json` — small, human-readable internal state (schema
+  version, last run IDs).
+- `.clarion/.gitignore` itself — this file.
+- `.clarion/runs//config.yaml` — the snapshot of `clarion.yaml` at run
+  time. Material for provenance replay.
+- `.clarion/runs//stats.json` — run statistics.
+- `.clarion/runs//partial.json` — present only for partial runs;
+  material for `--resume`.
+
+### Excluded
+
+- All SQLite WAL + SHM sidecars.
+- All shadow-DB intermediates.
+- `tmp/` and `logs/` (volatile scratch).
+- `runs/*/log.jsonl` (raw LLM bodies — audit-local, not commit-appropriate).
+
+### Out of scope for `.clarion/.gitignore`
+
+- `clarion.yaml` (the user-edited config) lives at the *project root*, not
+  inside `.clarion/`. Its tracking is governed by the project's own repo-root
+  `.gitignore`, which is the user's concern. Default posture: tracked.
+
+### Opt-out for users who don't want the DB committed
+
+`clarion.yaml:storage.commit_db: false` (post-Sprint-1 knob; WP6 authors the
+full `clarion.yaml` schema). When false, Clarion writes an additional
+`.clarion/.gitignore` line excluding `clarion.db`, and emits
+`clarion db sync push/pull` commands. Not implemented in Sprint 1; the knob
+is documented here so the future change has a home.
+
+## Alternatives Considered
+
+### Alternative 1: commit everything
+
+**Pros**: no ignore list to maintain.
+
+**Cons**: WAL sidecars break repos (they're process-local binary files); raw
+LLM bodies may contain material the user does not want public.
+
+**Why rejected**: blast radius of a single `git push` with `runs/*/log.jsonl`
+committed is unbounded.
+
+### Alternative 2: commit nothing
+
+**Pros**: simplest — `.clarion/` becomes entirely machine-local.
+
+**Cons**: loses the "shared analysis state" benefit — briefings and guidance
+are derived outputs that are expensive to rebuild. Small teams especially
+benefit from having them versioned alongside the code.
+
+**Why rejected**: the "enterprise rigor at lack of scale" posture favours
+committing analytic state for small-team workflows. Users who want machine-local
+analysis only opt out via `storage.commit_db: false`.
+
+### Alternative 3: commit the DB but use git-lfs by default
+
+**Pros**: keeps small-git-diff UX (LFS handles the binary file).
+
+**Cons**: requires git-lfs installed on every developer machine; makes `clarion
+install` a multi-tool setup; adds failure modes (lfs server availability, large
+file policy). v0.1 target workflows are solo/small-team where the straight-commit
+path works; LFS is a v0.2+ knob.
+
+**Why rejected**: premature infrastructure for the v0.1 audience.
+
+## Consequences
+
+### Positive
+
+- Every `clarion install` produces the same `.gitignore`. Ends per-developer
+  drift on "what should be committed."
+- WAL sidecars cannot accidentally land in a commit.
+- Raw LLM bodies stay local to the developer that ran the analysis.
+- `--shadow-db` intermediates (ADR-011) are excluded by the same list, so
+  users adopting that mode don't discover an ignore gap post-hoc.
+
+### Negative
+
+- Committed SQLite DBs diff poorly by default. Mitigation: the
+  `clarion db export --textual` / merge-helper path (detailed-design §3) is
+  the documented escape hatch.
+- Adding a new excluded pattern requires either a Clarion release or a
+  user-side `.clarion/.gitignore` edit. The post-v0.1 plan is to keep this
+  file tool-owned; users adding their own ignores put them in the repo-root
+  `.gitignore`, not here.
+
+### Neutral
+
+- `storage.commit_db: false` is a defined but unimplemented opt-out. Sprint 1
+  ships with the commit-the-DB default only.
+
+## Related Decisions
+
+- [ADR-011](./ADR-011-writer-actor-concurrency.md) — names the shadow-DB
+  intermediate; this ADR excludes it from git.
+- [ADR-014](./ADR-014-filigree-registry-backend.md) — cross-tool references
+  rely on `clarion.db` being available to readers (Filigree, Wardline); the
+  commit-by-default posture keeps those references resolvable across machines.
+
+## References
+
+- [detailed-design.md §3 File layout](../v0.1/detailed-design.md#file-layout) —
+  the prose version of this decision, now superseded by this ADR as the
+  normative source.
+- [wp1-scaffold.md UQ-WP1-04](../../implementation/sprint-1/wp1-scaffold.md) —
+  the sprint-local resolution this ADR formalises.
+```
+
+- [ ] **Step 6: Update the ADR index**
+
+Edit `/home/john/clarion/docs/clarion/adr/README.md` at line 32 (`| ADR-005 | ... | Backlog |`). Change to:
+
+```
+| ADR-005 | `.clarion/` git-committable by default; DB included, run logs excluded | Accepted |
+```
+
+If the ADR index has a separate "Accepted ADRs" list table elsewhere, also add a row for ADR-005 there (run `grep -n '^| ADR-' docs/clarion/adr/README.md` to locate). The important change is the status moving from `Backlog` to `Accepted`.
+
+- [ ] **Step 7: Run the tests**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-cli --test install
+```
+
+Expected: 4 tests pass.
+
+- [ ] **Step 8: Run clippy**
+
+```bash
+cd /home/john/clarion && cargo clippy -p clarion-cli --all-targets -- -D warnings
+```
+
+- [ ] **Step 9: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-cli/ docs/clarion/adr/ && git commit -m "$(cat <<'EOF'
+feat(wp1): clarion install subcommand; author ADR-005
+
+clarion install creates .clarion/{clarion.db,config.json,.gitignore} and
+a clarion.yaml stub at the project root per detailed-design.md §File
+layout. Refuses on existing .clarion/ (UQ-WP1-08). --force is recognised
+by clap but errors out — Sprint 1 does not implement overwrite.
+
+ADR-005 moved from Backlog to Accepted: .clarion/ git-tracking policy
+(committed: clarion.db, config.json, .gitignore, runs/*/config.yaml,
+stats.json, partial.json; excluded: WAL/SHM sidecars, *.shadow.db,
+runs/*/log.jsonl, tmp/, logs/). UQ-WP1-04 resolved.
+
+4 integration tests cover happy-path creation, migration-count
+verification, overwrite refusal, and --force stub message.
+EOF
+)"
+```
+
+---
+
+## Task 6: Writer-actor (L3)
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-storage/src/commands.rs`
+- Create: `/home/john/clarion/crates/clarion-storage/src/writer.rs`
+- Modify: `/home/john/clarion/crates/clarion-storage/src/lib.rs`
+- Create: `/home/john/clarion/crates/clarion-storage/tests/writer_actor.rs`
+
+- [ ] **Step 1: Write the command enum and entity record**
+
+Write `/home/john/clarion/crates/clarion-storage/src/commands.rs`:
+
+```rust
+//! Writer-actor command protocol (L3 lock-in).
+//!
+//! Per ADR-011, every persistent mutation is a `WriterCmd` variant. The
+//! writer task owns the sole `rusqlite::Connection`; callers enqueue
+//! commands via a bounded `mpsc::Sender`. Each variant carries
+//! a `oneshot::Sender` for the per-command ack (UQ-WP1-03 resolution).
+//!
+//! Sprint 1 ships four variants: BeginRun, InsertEntity, CommitRun,
+//! FailRun. Later WPs add InsertEdge, InsertFinding, etc. by appending
+//! variants — the pattern is frozen here.
+
+use tokio::sync::oneshot;
+
+use crate::error::StorageError;
+
+pub type Ack = oneshot::Sender>;
+
+/// Run status values. Extended in later WPs; Sprint 1 uses only
+/// `SkippedNoPlugins` (from `clarion analyze` without plugins wired) and
+/// `Failed` (explicit FailRun).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RunStatus {
+    /// Sprint 1 stub: analyze invoked with no plugins registered.
+    SkippedNoPlugins,
+    /// Normal successful completion.
+    Completed,
+    /// Explicit failure via FailRun.
+    Failed,
+}
+
+impl RunStatus {
+    pub fn as_str(self) -> &'static str {
+        match self {
+            RunStatus::SkippedNoPlugins => "skipped_no_plugins",
+            RunStatus::Completed => "completed",
+            RunStatus::Failed => "failed",
+        }
+    }
+}
+
+/// Plain-old-data entity record as seen by the writer. Content-hash and
+/// timestamps are supplied by callers; the writer does not compute them.
+#[derive(Debug, Clone)]
+pub struct EntityRecord {
+    pub id: String,
+    pub plugin_id: String,
+    pub kind: String,
+    pub name: String,
+    pub short_name: String,
+    pub parent_id: Option,
+    pub source_file_id: Option,
+    pub source_byte_start: Option,
+    pub source_byte_end: Option,
+    pub source_line_start: Option,
+    pub source_line_end: Option,
+    /// JSON string; writer inserts verbatim.
+    pub properties_json: String,
+    pub content_hash: Option,
+    pub summary_json: Option,
+    pub wardline_json: Option,
+    pub first_seen_commit: Option,
+    pub last_seen_commit: Option,
+    /// ISO-8601 UTC; writer inserts verbatim.
+    pub created_at: String,
+    pub updated_at: String,
+}
+
+/// All writer operations as a single enum so the actor loop exhausts
+/// everything via one match.
+#[derive(Debug)]
+pub enum WriterCmd {
+    /// Open a new run. The writer inserts a row into `runs` with status
+    /// `running`, begins an implicit transaction on the entities write
+    /// path, and binds `run_id` into its state.
+    BeginRun {
+        run_id: String,
+        config_json: String,
+        started_at: String,
+        ack: Ack<()>,
+    },
+    /// Insert an entity; also advances the per-batch insert counter and
+    /// commits the in-flight transaction if the batch boundary is crossed.
+    InsertEntity {
+        entity: EntityRecord,
+        ack: Ack<()>,
+    },
+    /// Commit the in-flight transaction, update the run row to the given
+    /// terminal status + completed_at + stats_json, and clear per-run
+    /// state.
+    CommitRun {
+        run_id: String,
+        status: RunStatus,
+        completed_at: String,
+        stats_json: String,
+        ack: Ack<()>,
+    },
+    /// Roll back the in-flight transaction, update the run row to
+    /// `failed`, and clear per-run state.
+    FailRun {
+        run_id: String,
+        reason: String,
+        completed_at: String,
+        ack: Ack<()>,
+    },
+}
+```
+
+- [ ] **Step 2: Write the writer-actor itself**
+
+Write `/home/john/clarion/crates/clarion-storage/src/writer.rs`:
+
+```rust
+//! Writer-actor implementation (L3 lock-in) per ADR-011.
+//!
+//! The actor owns the sole write `rusqlite::Connection`. Callers submit
+//! commands via `Writer::sender()`. The actor loop pulls one command at a
+//! time, applies the mutation inside an implicit transaction bound to the
+//! current run, and commits every `batch_size` entity inserts (the
+//! "per-N-files" transaction pattern, default N=50 per ADR-011).
+//!
+//! UQ-WP1-03 resolution: the `commits_observed` `Arc` is
+//! incremented on every COMMIT issued by the actor. Tests read it to
+//! verify batch-boundary commits fire at the expected cadence. It is
+//! present in release builds as a no-op counter; no `#[cfg(test)]` gating
+//! is used.
+
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::Arc;
+
+use rusqlite::{params, Connection};
+use tokio::sync::{mpsc, oneshot};
+use tokio::task::JoinHandle;
+
+use crate::commands::{Ack, EntityRecord, RunStatus, WriterCmd};
+use crate::error::{Result, StorageError};
+use crate::pragma;
+
+/// Default transaction batch size per ADR-011.
+pub const DEFAULT_BATCH_SIZE: usize = 50;
+
+/// Default mpsc channel capacity per ADR-011.
+pub const DEFAULT_CHANNEL_CAPACITY: usize = 256;
+
+pub struct Writer {
+    tx: mpsc::Sender,
+    pub commits_observed: Arc,
+}
+
+impl Writer {
+    /// Spawn the writer-actor on the current tokio runtime.
+    ///
+    /// Returns the `Writer` handle and the `JoinHandle` of the actor task.
+    /// Callers await the `JoinHandle` at shutdown to ensure the actor has
+    /// flushed any pending commit.
+    pub fn spawn(
+        db_path: std::path::PathBuf,
+        batch_size: usize,
+        channel_capacity: usize,
+    ) -> Result<(Self, JoinHandle>)> {
+        let (tx, rx) = mpsc::channel(channel_capacity);
+        let commits_observed = Arc::new(AtomicUsize::new(0));
+        let commits_for_actor = commits_observed.clone();
+        let handle = tokio::task::spawn_blocking(move || -> Result<()> {
+            let mut conn = Connection::open(&db_path)?;
+            pragma::apply_write_pragmas(&conn)?;
+            run_actor(rx, &mut conn, batch_size, commits_for_actor)
+        });
+        Ok((
+            Writer { tx, commits_observed },
+            // spawn_blocking's JoinHandle has the same shape as spawn's for
+            // `.await.map_err(...)?` purposes.
+            handle,
+        ))
+    }
+
+    pub fn sender(&self) -> mpsc::Sender {
+        self.tx.clone()
+    }
+
+    /// Convenience: send a command and await its ack.
+    pub async fn send_wait(&self, build: F) -> Result
+    where
+        F: FnOnce(oneshot::Sender>) -> WriterCmd,
+        T: 'static,
+    {
+        let (tx, rx) = oneshot::channel();
+        let cmd = build(tx);
+        self.tx
+            .send(cmd)
+            .await
+            .map_err(|_| StorageError::WriterGone)?;
+        rx.await.map_err(|_| StorageError::WriterNoResponse)?
+    }
+}
+
+fn run_actor(
+    mut rx: mpsc::Receiver,
+    conn: &mut Connection,
+    batch_size: usize,
+    commits_observed: Arc,
+) -> Result<()> {
+    let mut state = ActorState::new(batch_size);
+
+    while let Some(cmd) = rx.blocking_recv() {
+        match cmd {
+            WriterCmd::BeginRun {
+                run_id,
+                config_json,
+                started_at,
+                ack,
+            } => {
+                reply(ack, begin_run(conn, &mut state, &run_id, &config_json, &started_at));
+            }
+            WriterCmd::InsertEntity { entity, ack } => {
+                let res = insert_entity(conn, &mut state, &entity, &commits_observed);
+                reply(ack, res);
+            }
+            WriterCmd::CommitRun {
+                run_id,
+                status,
+                completed_at,
+                stats_json,
+                ack,
+            } => {
+                let res = commit_run(
+                    conn,
+                    &mut state,
+                    &run_id,
+                    status,
+                    &completed_at,
+                    &stats_json,
+                    &commits_observed,
+                );
+                reply(ack, res);
+            }
+            WriterCmd::FailRun {
+                run_id,
+                reason,
+                completed_at,
+                ack,
+            } => {
+                let res = fail_run(conn, &mut state, &run_id, &reason, &completed_at);
+                reply(ack, res);
+            }
+        }
+    }
+    // Channel closed. Best-effort flush.
+    if state.in_tx {
+        let _ = conn.execute_batch("ROLLBACK");
+    }
+    Ok(())
+}
+
+fn reply(ack: Ack, result: Result) {
+    // If the caller dropped the receiver, we discard the result. This is
+    // correct behaviour — the writer is still responsible for its own
+    // durability, and the caller chose to stop caring.
+    let _ = ack.send(result);
+}
+
+struct ActorState {
+    batch_size: usize,
+    /// Inserts accumulated in the current transaction.
+    inserts_in_batch: usize,
+    /// True if BEGIN has been issued and no COMMIT/ROLLBACK has fired.
+    in_tx: bool,
+    /// The run currently in progress, if any.
+    current_run: Option,
+}
+
+impl ActorState {
+    fn new(batch_size: usize) -> Self {
+        Self {
+            batch_size,
+            inserts_in_batch: 0,
+            in_tx: false,
+            current_run: None,
+        }
+    }
+}
+
+fn begin_run(
+    conn: &mut Connection,
+    state: &mut ActorState,
+    run_id: &str,
+    config_json: &str,
+    started_at: &str,
+) -> Result<()> {
+    if state.current_run.is_some() {
+        return Err(StorageError::Sqlite(rusqlite::Error::InvalidQuery));
+    }
+    conn.execute(
+        "INSERT INTO runs (id, started_at, completed_at, config, stats, status) \
+         VALUES (?1, ?2, NULL, ?3, '{}', 'running')",
+        params![run_id, started_at, config_json],
+    )?;
+    conn.execute_batch("BEGIN")?;
+    state.in_tx = true;
+    state.inserts_in_batch = 0;
+    state.current_run = Some(run_id.to_owned());
+    Ok(())
+}
+
+fn insert_entity(
+    conn: &mut Connection,
+    state: &mut ActorState,
+    entity: &EntityRecord,
+    commits_observed: &AtomicUsize,
+) -> Result<()> {
+    if !state.in_tx {
+        conn.execute_batch("BEGIN")?;
+        state.in_tx = true;
+    }
+    conn.execute(
+        "INSERT INTO entities ( \
+            id, plugin_id, kind, name, short_name, \
+            parent_id, source_file_id, \
+            source_byte_start, source_byte_end, \
+            source_line_start, source_line_end, \
+            properties, content_hash, summary, wardline, \
+            first_seen_commit, last_seen_commit, \
+            created_at, updated_at \
+         ) VALUES ( \
+            ?1, ?2, ?3, ?4, ?5, \
+            ?6, ?7, \
+            ?8, ?9, \
+            ?10, ?11, \
+            ?12, ?13, ?14, ?15, \
+            ?16, ?17, \
+            ?18, ?19 \
+         )",
+        params![
+            entity.id,
+            entity.plugin_id,
+            entity.kind,
+            entity.name,
+            entity.short_name,
+            entity.parent_id,
+            entity.source_file_id,
+            entity.source_byte_start,
+            entity.source_byte_end,
+            entity.source_line_start,
+            entity.source_line_end,
+            entity.properties_json,
+            entity.content_hash,
+            entity.summary_json,
+            entity.wardline_json,
+            entity.first_seen_commit,
+            entity.last_seen_commit,
+            entity.created_at,
+            entity.updated_at,
+        ],
+    )?;
+    state.inserts_in_batch += 1;
+    if state.inserts_in_batch >= state.batch_size {
+        conn.execute_batch("COMMIT")?;
+        commits_observed.fetch_add(1, Ordering::Relaxed);
+        state.in_tx = false;
+        state.inserts_in_batch = 0;
+        // Open the next batch eagerly so the next insert doesn't pay
+        // another BEGIN round-trip.
+        conn.execute_batch("BEGIN")?;
+        state.in_tx = true;
+    }
+    Ok(())
+}
+
+fn commit_run(
+    conn: &mut Connection,
+    state: &mut ActorState,
+    run_id: &str,
+    status: RunStatus,
+    completed_at: &str,
+    stats_json: &str,
+    commits_observed: &AtomicUsize,
+) -> Result<()> {
+    if state.in_tx {
+        conn.execute_batch("COMMIT")?;
+        commits_observed.fetch_add(1, Ordering::Relaxed);
+        state.in_tx = false;
+    }
+    conn.execute(
+        "UPDATE runs SET status = ?1, completed_at = ?2, stats = ?3 WHERE id = ?4",
+        params![status.as_str(), completed_at, stats_json, run_id],
+    )?;
+    state.current_run = None;
+    state.inserts_in_batch = 0;
+    Ok(())
+}
+
+fn fail_run(
+    conn: &mut Connection,
+    state: &mut ActorState,
+    run_id: &str,
+    reason: &str,
+    completed_at: &str,
+) -> Result<()> {
+    if state.in_tx {
+        let _ = conn.execute_batch("ROLLBACK");
+        state.in_tx = false;
+    }
+    let stats_json = serde_json::json!({ "failure_reason": reason }).to_string();
+    conn.execute(
+        "UPDATE runs SET status = 'failed', completed_at = ?1, stats = ?2 WHERE id = ?3",
+        params![completed_at, stats_json, run_id],
+    )?;
+    state.current_run = None;
+    state.inserts_in_batch = 0;
+    Ok(())
+}
+```
+
+- [ ] **Step 3: Export the new modules**
+
+Modify `/home/john/clarion/crates/clarion-storage/src/lib.rs` to:
+
+```rust
+//! clarion-storage — SQLite layer, writer-actor, reader pool.
+
+pub mod commands;
+pub mod error;
+pub mod pragma;
+pub mod reader;
+pub mod schema;
+pub mod writer;
+
+pub use commands::{EntityRecord, RunStatus, WriterCmd};
+pub use error::{Result, StorageError};
+pub use reader::ReaderPool;
+pub use writer::{Writer, DEFAULT_BATCH_SIZE, DEFAULT_CHANNEL_CAPACITY};
+```
+
+- [ ] **Step 4: Write the integration tests**
+
+Write `/home/john/clarion/crates/clarion-storage/tests/writer_actor.rs`:
+
+```rust
+//! Writer-actor integration tests.
+//!
+//! Covers: round-trip insert, per-N-batch commit cadence, FailRun rollback.
+
+use std::sync::atomic::Ordering;
+
+use rusqlite::Connection;
+use tokio::sync::oneshot;
+
+use clarion_storage::{
+    commands::{EntityRecord, RunStatus, WriterCmd},
+    pragma, schema, ReaderPool, Writer,
+};
+
+fn prepared_db(dir: &tempfile::TempDir) -> std::path::PathBuf {
+    let path = dir.path().join("clarion.db");
+    let mut conn = Connection::open(&path).unwrap();
+    pragma::apply_write_pragmas(&conn).unwrap();
+    schema::apply_migrations(&mut conn).unwrap();
+    path
+}
+
+fn now_iso() -> String {
+    // Fixed per-test timestamp keeps deterministic assertions trivial.
+    "2026-04-18T00:00:00.000Z".to_owned()
+}
+
+fn make_entity(id: &str) -> EntityRecord {
+    EntityRecord {
+        id: id.to_owned(),
+        plugin_id: "python".to_owned(),
+        kind: "function".to_owned(),
+        name: "demo.hello".to_owned(),
+        short_name: "hello".to_owned(),
+        parent_id: None,
+        source_file_id: None,
+        source_byte_start: None,
+        source_byte_end: None,
+        source_line_start: None,
+        source_line_end: None,
+        properties_json: "{}".to_owned(),
+        content_hash: None,
+        summary_json: None,
+        wardline_json: None,
+        first_seen_commit: None,
+        last_seen_commit: None,
+        created_at: now_iso(),
+        updated_at: now_iso(),
+    }
+}
+
+async fn send(
+    tx: &tokio::sync::mpsc::Sender,
+    build: impl FnOnce(oneshot::Sender>) -> WriterCmd,
+) -> Result {
+    let (ack_tx, ack_rx) = oneshot::channel();
+    tx.send(build(ack_tx)).await.unwrap();
+    ack_rx.await.unwrap()
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn round_trip_insert_persists_entity() {
+    let dir = tempfile::tempdir().unwrap();
+    let path = prepared_db(&dir);
+    let (writer, handle) = Writer::spawn(path.clone(), 50, 256).unwrap();
+    let tx = writer.sender();
+
+    send::<()>(&tx, |ack| WriterCmd::BeginRun {
+        run_id: "run-1".into(),
+        config_json: "{}".into(),
+        started_at: now_iso(),
+        ack,
+    })
+    .await
+    .unwrap();
+
+    send::<()>(&tx, |ack| WriterCmd::InsertEntity {
+        entity: make_entity("python:function:demo.hello"),
+        ack,
+    })
+    .await
+    .unwrap();
+
+    send::<()>(&tx, |ack| WriterCmd::CommitRun {
+        run_id: "run-1".into(),
+        status: RunStatus::Completed,
+        completed_at: now_iso(),
+        stats_json: "{}".into(),
+        ack,
+    })
+    .await
+    .unwrap();
+
+    drop(tx);
+    drop(writer);
+    handle.await.unwrap().unwrap();
+
+    let pool = ReaderPool::open(&path, 2).unwrap();
+    let count: i64 = pool
+        .with_reader(|conn| {
+            let n: i64 = conn
+                .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0))?;
+            Ok(n)
+        })
+        .await
+        .unwrap();
+    assert_eq!(count, 1);
+
+    let kind: String = pool
+        .with_reader(|conn| {
+            let k: String = conn.query_row(
+                "SELECT kind FROM entities WHERE id = ?1",
+                rusqlite::params!["python:function:demo.hello"],
+                |row| row.get(0),
+            )?;
+            Ok(k)
+        })
+        .await
+        .unwrap();
+    assert_eq!(kind, "function");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn batch_size_fifty_commits_every_fifty_inserts() {
+    let dir = tempfile::tempdir().unwrap();
+    let path = prepared_db(&dir);
+    let (writer, handle) = Writer::spawn(path.clone(), 50, 256).unwrap();
+    let tx = writer.sender();
+
+    send::<()>(&tx, |ack| WriterCmd::BeginRun {
+        run_id: "run-1".into(),
+        config_json: "{}".into(),
+        started_at: now_iso(),
+        ack,
+    })
+    .await
+    .unwrap();
+
+    for i in 0..150 {
+        let id = format!("python:function:demo.f{i:03}");
+        send::<()>(&tx, |ack| WriterCmd::InsertEntity {
+            entity: make_entity(&id),
+            ack,
+        })
+        .await
+        .unwrap();
+    }
+
+    // At 150 inserts with batch_size=50, three batch-boundary commits have
+    // fired. CommitRun will fire a fourth on the trailing (empty) batch.
+    assert_eq!(writer.commits_observed.load(Ordering::Relaxed), 3);
+
+    send::<()>(&tx, |ack| WriterCmd::CommitRun {
+        run_id: "run-1".into(),
+        status: RunStatus::Completed,
+        completed_at: now_iso(),
+        stats_json: "{}".into(),
+        ack,
+    })
+    .await
+    .unwrap();
+
+    // CommitRun opens no new tx; the batch is empty, so COMMIT is a no-op
+    // from SQLite's view but our actor still issues one to close the tx
+    // state. Contract: commits_observed now equals 4 (3 batch boundaries
+    // + 1 CommitRun).
+    assert_eq!(writer.commits_observed.load(Ordering::Relaxed), 4);
+
+    drop(tx);
+    drop(writer);
+    handle.await.unwrap().unwrap();
+
+    let pool = ReaderPool::open(&path, 2).unwrap();
+    let count: i64 = pool
+        .with_reader(|conn| {
+            let n: i64 = conn
+                .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0))?;
+            Ok(n)
+        })
+        .await
+        .unwrap();
+    assert_eq!(count, 150);
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn fail_run_rolls_back_pending_inserts() {
+    let dir = tempfile::tempdir().unwrap();
+    let path = prepared_db(&dir);
+    let (writer, handle) = Writer::spawn(path.clone(), 50, 256).unwrap();
+    let tx = writer.sender();
+
+    send::<()>(&tx, |ack| WriterCmd::BeginRun {
+        run_id: "run-fail".into(),
+        config_json: "{}".into(),
+        started_at: now_iso(),
+        ack,
+    })
+    .await
+    .unwrap();
+
+    for i in 0..10 {
+        let id = format!("python:function:demo.g{i:03}");
+        send::<()>(&tx, |ack| WriterCmd::InsertEntity {
+            entity: make_entity(&id),
+            ack,
+        })
+        .await
+        .unwrap();
+    }
+
+    send::<()>(&tx, |ack| WriterCmd::FailRun {
+        run_id: "run-fail".into(),
+        reason: "deliberate test failure".into(),
+        completed_at: now_iso(),
+        ack,
+    })
+    .await
+    .unwrap();
+
+    drop(tx);
+    drop(writer);
+    handle.await.unwrap().unwrap();
+
+    let pool = ReaderPool::open(&path, 2).unwrap();
+    let entity_count: i64 = pool
+        .with_reader(|conn| {
+            let n: i64 = conn
+                .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0))?;
+            Ok(n)
+        })
+        .await
+        .unwrap();
+    assert_eq!(entity_count, 0, "FailRun did not roll back inserts");
+
+    let status: String = pool
+        .with_reader(|conn| {
+            let s: String = conn.query_row(
+                "SELECT status FROM runs WHERE id = 'run-fail'",
+                [],
+                |row| row.get(0),
+            )?;
+            Ok(s)
+        })
+        .await
+        .unwrap();
+    assert_eq!(status, "failed");
+}
+```
+
+- [ ] **Step 5: Run the tests**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-storage --test writer_actor
+```
+
+Expected: 3 tests pass. The `batch_size_fifty_commits_every_fifty_inserts` test is the L3 lock-in proof.
+
+- [ ] **Step 6: Run every storage-crate test**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-storage
+```
+
+Expected: 12 tests pass in total (7 schema + 2 reader + 3 writer).
+
+- [ ] **Step 7: Clippy clean**
+
+```bash
+cd /home/john/clarion && cargo clippy -p clarion-storage --all-targets -- -D warnings
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-storage/ && git commit -m "$(cat <<'EOF'
+feat(wp1): L3 writer-actor (tokio::task) with per-N transaction batch
+
+Writer::spawn() starts a tokio::spawn_blocking task owning the sole write
+rusqlite::Connection. Callers enqueue WriterCmd variants via a bounded
+mpsc channel (ADR-011 defaults: capacity 256, batch size 50). Each command
+carries a oneshot::Sender ack for per-command replies (UQ-WP1-03).
+
+WriterCmd variants: BeginRun, InsertEntity, CommitRun, FailRun. InsertEntity
+commits at batch boundaries; CommitRun/FailRun finalise run state. FailRun
+rolls back the pending transaction before updating the runs row.
+
+commits_observed (Arc) counts COMMIT statements fired by the
+actor — exposed as a normal field on Writer so tests can assert the per-N
+cadence without #[cfg(test)] gating on the hot loop.
+
+3 integration tests cover round-trip insert, batch-boundary commit cadence
+(150 inserts → 3 in-flight commits + 1 CommitRun = 4), and FailRun rollback.
+EOF
+)"
+```
+
+---
+
+## Task 7: `clarion analyze` skeleton (no plugin)
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-cli/src/analyze.rs`
+- Modify: `/home/john/clarion/crates/clarion-cli/src/main.rs`
+- Create: `/home/john/clarion/crates/clarion-cli/tests/analyze.rs`
+
+- [ ] **Step 1: Implement the analyze subcommand**
+
+Write `/home/john/clarion/crates/clarion-cli/src/analyze.rs`:
+
+```rust
+//! `clarion analyze` — Sprint 1 skeleton.
+//!
+//! Opens .clarion/clarion.db, begins a run, logs a warning that no plugins
+//! are wired, and commits the run with status `skipped_no_plugins`. WP2
+//! replaces this body with real plugin spawning.
+
+use std::path::PathBuf;
+
+use anyhow::{bail, Context, Result};
+use tokio::sync::oneshot;
+use uuid::Uuid;
+
+use clarion_storage::{
+    commands::{RunStatus, WriterCmd},
+    Writer, DEFAULT_BATCH_SIZE, DEFAULT_CHANNEL_CAPACITY,
+};
+
+pub async fn run(project_path: PathBuf) -> Result<()> {
+    let project_root = project_path
+        .canonicalize()
+        .with_context(|| format!("cannot canonicalise path {}", project_path.display()))?;
+    let clarion_dir = project_root.join(".clarion");
+    if !clarion_dir.exists() {
+        bail!(
+            "{} has no .clarion/ directory. Run `clarion install` first.",
+            project_root.display()
+        );
+    }
+    let db_path = clarion_dir.join("clarion.db");
+
+    let (writer, handle) =
+        Writer::spawn(db_path, DEFAULT_BATCH_SIZE, DEFAULT_CHANNEL_CAPACITY)
+            .context("spawn writer actor")?;
+    let tx = writer.sender();
+    let run_id = Uuid::new_v4().to_string();
+    let now = chrono_like_now();
+
+    let (ack_tx, ack_rx) = oneshot::channel();
+    tx.send(WriterCmd::BeginRun {
+        run_id: run_id.clone(),
+        config_json: "{}".into(),
+        started_at: now.clone(),
+        ack: ack_tx,
+    })
+    .await
+    .map_err(|_| anyhow::anyhow!("writer actor closed before BeginRun"))?;
+    ack_rx
+        .await
+        .map_err(|_| anyhow::anyhow!("writer actor dropped ack"))?
+        .context("BeginRun")?;
+
+    tracing::info!(
+        run_id = %run_id,
+        "no plugins registered (WP2 will wire this)"
+    );
+
+    let (ack_tx, ack_rx) = oneshot::channel();
+    tx.send(WriterCmd::CommitRun {
+        run_id: run_id.clone(),
+        status: RunStatus::SkippedNoPlugins,
+        completed_at: now,
+        stats_json: r#"{"entities_inserted":0}"#.into(),
+        ack: ack_tx,
+    })
+    .await
+    .map_err(|_| anyhow::anyhow!("writer actor closed before CommitRun"))?;
+    ack_rx
+        .await
+        .map_err(|_| anyhow::anyhow!("writer actor dropped ack"))?
+        .context("CommitRun")?;
+
+    drop(tx);
+    drop(writer);
+    handle.await.map_err(|e| anyhow::anyhow!("writer actor panic: {e}"))??;
+
+    println!("analyze complete: run {run_id} skipped_no_plugins");
+    Ok(())
+}
+
+fn chrono_like_now() -> String {
+    // We avoid adding a chrono dependency just to format a timestamp in
+    // Sprint 1. SystemTime + a small formatter lands an ISO-8601 UTC
+    // string adequate for the `runs.started_at` text column.
+    use std::time::{SystemTime, UNIX_EPOCH};
+    let d = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .expect("clock before 1970");
+    let secs = d.as_secs();
+    let millis = d.subsec_millis();
+    let (y, mo, da, h, mi, se) = gmtime(secs);
+    format!("{y:04}-{mo:02}-{da:02}T{h:02}:{mi:02}:{se:02}.{millis:03}Z")
+}
+
+fn gmtime(mut secs: u64) -> (u32, u32, u32, u32, u32, u32) {
+    let se = (secs % 60) as u32;
+    secs /= 60;
+    let mi = (secs % 60) as u32;
+    secs /= 60;
+    let h = (secs % 24) as u32;
+    secs /= 24;
+    // Days since epoch → date. Algorithm: Howard Hinnant's date. Adapted.
+    let mut z = secs as i64;
+    z += 719_468;
+    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+    let doe = (z - era * 146_097) as u64;
+    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
+    let y = yoe as i64 + era * 400;
+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+    let mp = (5 * doy + 2) / 153;
+    let da = (doy - (153 * mp + 2) / 5 + 1) as u32;
+    let mo = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
+    let y = if mo <= 2 { y + 1 } else { y };
+    (y as u32, mo, da, h, mi, se)
+}
+```
+
+Add the `uuid` dependency to `/home/john/clarion/crates/clarion-cli/Cargo.toml`:
+
+```toml
+[dependencies]
+# ... existing entries above ...
+uuid = { version = "1", features = ["v4"] }
+```
+
+Also add `uuid` to the root `Cargo.toml` `[workspace.dependencies]`:
+
+```toml
+uuid = { version = "1", features = ["v4"] }
+```
+
+Then reference it from `clarion-cli` with `uuid.workspace = true`. (Prefer the workspace form — keeps version bumps centralised.)
+
+- [ ] **Step 2: Wire analyze into `main.rs`**
+
+Replace `/home/john/clarion/crates/clarion-cli/src/main.rs` with:
+
+```rust
+mod analyze;
+mod cli;
+mod install;
+
+use anyhow::Result;
+use clap::Parser;
+
+fn main() -> Result<()> {
+    init_tracing();
+    let cli = cli::Cli::parse();
+    match cli.command {
+        cli::Command::Install { force, path } => install::run(path, force),
+        cli::Command::Analyze { path } => {
+            let rt = tokio::runtime::Builder::new_multi_thread()
+                .enable_all()
+                .build()?;
+            rt.block_on(analyze::run(path))
+        }
+    }
+}
+
+fn init_tracing() {
+    use tracing_subscriber::EnvFilter;
+    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
+    tracing_subscriber::fmt().with_env_filter(filter).with_target(false).init();
+}
+```
+
+- [ ] **Step 3: Write the integration tests**
+
+Write `/home/john/clarion/crates/clarion-cli/tests/analyze.rs`:
+
+```rust
+//! `clarion analyze` Sprint-1 integration test.
+
+use assert_cmd::Command;
+use rusqlite::Connection;
+
+fn clarion_bin() -> Command {
+    Command::cargo_bin("clarion").expect("clarion binary")
+}
+
+#[test]
+fn analyze_without_plugins_writes_skipped_run_row() {
+    let dir = tempfile::tempdir().unwrap();
+    clarion_bin()
+        .args(["install", "--path"])
+        .arg(dir.path())
+        .assert()
+        .success();
+
+    clarion_bin()
+        .args(["analyze"])
+        .arg(dir.path())
+        .assert()
+        .success();
+
+    let conn = Connection::open(dir.path().join(".clarion/clarion.db")).unwrap();
+    let (count, status): (i64, String) = conn
+        .query_row(
+            "SELECT COUNT(*), COALESCE(MAX(status), '') FROM runs",
+            [],
+            |row| Ok((row.get(0)?, row.get(1)?)),
+        )
+        .unwrap();
+    assert_eq!(count, 1);
+    assert_eq!(status, "skipped_no_plugins");
+
+    let entity_count: i64 = conn
+        .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0))
+        .unwrap();
+    assert_eq!(entity_count, 0);
+}
+
+#[test]
+fn analyze_fails_cleanly_if_clarion_dir_missing() {
+    let dir = tempfile::tempdir().unwrap();
+    let out = clarion_bin()
+        .args(["analyze"])
+        .arg(dir.path())
+        .assert()
+        .failure();
+    let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap();
+    assert!(
+        stderr.contains("clarion install"),
+        "error did not point operator at install: {stderr}"
+    );
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-cli --test analyze
+```
+
+Expected: 2 tests pass.
+
+- [ ] **Step 5: Clippy clean**
+
+```bash
+cd /home/john/clarion && cargo clippy -p clarion-cli --all-targets -- -D warnings
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /home/john/clarion && git add Cargo.toml crates/clarion-cli/ && git commit -m "$(cat <<'EOF'
+feat(wp1): clarion analyze skeleton (plugin wiring deferred to WP2)
+
+clarion analyze opens .clarion/clarion.db, BeginRun → CommitRun with
+status 'skipped_no_plugins'. Warns via tracing::info! that no plugins
+are wired. Fails cleanly with a `clarion install`-pointing message if
+.clarion/ is missing.
+
+uuid v4 added as a workspace dependency for run-id generation. Minimal
+inline gmtime() avoids pulling chrono solely for ISO-8601 formatting;
+later WPs that need richer time handling can promote chrono to a
+workspace dependency then.
+
+2 integration tests cover the happy path (one runs row, zero entities)
+and the missing-.clarion/ error path.
+EOF
+)"
+```
+
+---
+
+## Task 8: LlmProvider trait stub
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-core/src/llm_provider.rs`
+- Modify: `/home/john/clarion/crates/clarion-core/src/lib.rs`
+
+- [ ] **Step 1: Write the trait and test**
+
+Write `/home/john/clarion/crates/clarion-core/src/llm_provider.rs`:
+
+```rust
+//! LlmProvider trait stub.
+//!
+//! WP6 (summary-cache + prompt dispatch) fills this out. Sprint 1 defines
+//! the hook point so the trait has a stable import path from day one.
+//! `NoopProvider` panics if its `name()` is called — Sprint 1 has no
+//! code path that legitimately calls it, so panic is a louder bug signal
+//! than a silent default.
+
+pub trait LlmProvider: Send + Sync {
+    /// Human-readable provider identifier.
+    fn name(&self) -> &str;
+}
+
+/// Stub provider used in Sprint 1 code paths that take a provider
+/// argument. Calling `name()` panics — if you see this panic, something
+/// in the WP1 code is reaching for a real provider before WP6 lands.
+pub struct NoopProvider;
+
+impl LlmProvider for NoopProvider {
+    fn name(&self) -> &str {
+        panic!("NoopProvider::name called — WP6 should have replaced this by now")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn noop_provider_implements_trait() {
+        fn assert_trait(_: &T) {}
+        let p = NoopProvider;
+        assert_trait(&p);
+    }
+
+    #[test]
+    #[should_panic(expected = "NoopProvider::name called")]
+    fn noop_provider_panics_on_name() {
+        let p = NoopProvider;
+        let _ = p.name();
+    }
+}
+```
+
+- [ ] **Step 2: Export from `lib.rs`**
+
+Modify `/home/john/clarion/crates/clarion-core/src/lib.rs` to:
+
+```rust
+//! clarion-core — domain types, identifiers, and provider traits.
+
+pub mod entity_id;
+pub mod llm_provider;
+
+pub use entity_id::{entity_id, EntityId, EntityIdError};
+pub use llm_provider::{LlmProvider, NoopProvider};
+```
+
+- [ ] **Step 3: Run the tests**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-core
+```
+
+Expected: all clarion-core tests pass (13 entity_id + 2 llm_provider = 15).
+
+- [ ] **Step 4: Clippy clean**
+
+```bash
+cd /home/john/clarion && cargo clippy -p clarion-core --all-targets -- -D warnings
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-core/ && git commit -m "$(cat <<'EOF'
+feat(wp1): LlmProvider trait stub for WP6
+
+LlmProvider + NoopProvider in clarion-core. NoopProvider::name() panics
+loudly — if it ever fires, some WP1 code is reaching for a real provider
+before WP6 is wired. 2 tests: trait implementation and panic contract.
+EOF
+)"
+```
+
+---
+
+## Task 9: End-to-end WP1 smoke test
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-cli/tests/wp1_e2e.rs`
+
+- [ ] **Step 1: Write the end-to-end smoke test**
+
+Write `/home/john/clarion/crates/clarion-cli/tests/wp1_e2e.rs`:
+
+```rust
+//! End-to-end WP1 smoke test — the minimum that must work at WP1 close.
+//!
+//! Mirrors docs/implementation/sprint-1/README.md §3 demo script for
+//! Sprint 1 WP1 scope (no plugin, no entities — those land in WP2 + WP3).
+
+use assert_cmd::Command;
+use rusqlite::Connection;
+
+fn clarion_bin() -> Command {
+    Command::cargo_bin("clarion").expect("clarion binary")
+}
+
+#[test]
+fn wp1_walking_skeleton_end_to_end() {
+    let dir = tempfile::tempdir().unwrap();
+
+    // Step 1: clarion install
+    clarion_bin()
+        .args(["install", "--path"])
+        .arg(dir.path())
+        .assert()
+        .success();
+
+    let clarion_dir = dir.path().join(".clarion");
+    assert!(clarion_dir.join("clarion.db").exists());
+    assert!(clarion_dir.join("config.json").exists());
+    assert!(clarion_dir.join(".gitignore").exists());
+    assert!(dir.path().join("clarion.yaml").exists());
+
+    // Step 2: clarion analyze (no plugins yet — WP2 wires them)
+    clarion_bin()
+        .args(["analyze"])
+        .arg(dir.path())
+        .assert()
+        .success();
+
+    // Step 3: verify expected shape in the DB.
+    let conn = Connection::open(clarion_dir.join("clarion.db")).unwrap();
+
+    let migration_version: i64 = conn
+        .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| row.get(0))
+        .unwrap();
+    assert_eq!(migration_version, 1, "schema not on migration 1");
+
+    let runs_count: i64 = conn
+        .query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0))
+        .unwrap();
+    assert_eq!(runs_count, 1, "expected exactly one run row");
+
+    let run_status: String = conn
+        .query_row("SELECT status FROM runs", [], |row| row.get(0))
+        .unwrap();
+    assert_eq!(run_status, "skipped_no_plugins");
+
+    let entity_count: i64 = conn
+        .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0))
+        .unwrap();
+    assert_eq!(entity_count, 0);
+
+    // WP2+WP3 extend this test to assert a non-zero entity count with the
+    // expected 3-segment ID (L2 format `python:function:demo.hello`).
+}
+```
+
+- [ ] **Step 2: Run the full workspace test suite one last time**
+
+```bash
+cd /home/john/clarion && cargo nextest run --workspace --all-features
+```
+
+Expected: all tests green. Count should be ~20 (15 core + 2 reader + 7 schema + 3 writer + 4 install + 2 analyze + 1 e2e = in that neighbourhood; exact count depends on which negative cases expanded).
+
+- [ ] **Step 3: Release-profile build**
+
+```bash
+cd /home/john/clarion && cargo build --workspace --release
+```
+
+Expected: clean compile, `target/release/clarion` exists. If any warning-as-error surfaces, fix it in the relevant crate's code path, not by downgrading the lint.
+
+- [ ] **Step 4: Full ADR-023 gate sweep before commit**
+
+Every gate below must exit 0 before Task 9 commits. CI runs the same set against the PR; running them locally first avoids PR-cycle churn.
+
+```bash
+cd /home/john/clarion && cargo fmt --all -- --check
+cd /home/john/clarion && cargo clippy --workspace --all-targets --all-features -- -D warnings
+cd /home/john/clarion && cargo doc --workspace --no-deps --all-features
+cd /home/john/clarion && cargo deny check
+```
+
+If any gate fails, fix in-place (don't loosen the lint, don't add allowlist entries without a commit-message justification, don't skip `--all-features`).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-cli/tests/wp1_e2e.rs && git commit -m "$(cat <<'EOF'
+test(wp1): end-to-end smoke test
+
+wp1_e2e.rs runs the README §3 demo script at WP1 scope: clarion install
+creates .clarion/; clarion analyze commits a run with status
+skipped_no_plugins and zero entities; migration 0001 recorded as
+schema_version 1. WP2+WP3 extend this test with non-zero entity asserts.
+EOF
+)"
+```
+
+---
+
+## Task 10: Sign-off ladder updates
+
+**Files:**
+- Modify: `/home/john/clarion/docs/implementation/sprint-1/signoffs.md`
+- Modify: `/home/john/clarion/docs/implementation/sprint-1/README.md`
+- Modify: `/home/john/clarion/docs/implementation/sprint-1/wp1-scaffold.md`
+
+- [ ] **Step 1: Tick Tier A.1.* boxes in `signoffs.md`**
+
+In `/home/john/clarion/docs/implementation/sprint-1/signoffs.md`, change each Tier A.1 checkbox from `- [ ]` to `- [x]` after verifying the cited proof (commit hash, test-run log, or doc commit). For lock-in rows A.1.3, A.1.4, A.1.5, fill in the `locked on ______` date — use the date the final WP1 commit lands (execute `git log -1 --format=%as HEAD` to get it). Also tick A.1.6, A.1.7, A.1.8, A.1.9, A.1.10.
+
+Do **not** tick Tier A.2, A.3, A.4, A.5, A.6 — those belong to WP2/WP3/sprint-close.
+
+- [ ] **Step 2: Stamp lock-in dates in README.md §4**
+
+In `/home/john/clarion/docs/implementation/sprint-1/README.md` §4 Lock-in summary, annotate L1, L2, L3 rows with the same `locked on ` stamp added to signoffs.md.
+
+- [ ] **Step 3: Mark UQ-WP1-* resolved in `wp1-scaffold.md §5`**
+
+Each UQ-WP1-01 through UQ-WP1-09 entry gets a `**Resolved**: ` line that matches the UQ's current state:
+
+- UQ-WP1-01 — resolved by ADR-011 (rusqlite).
+- UQ-WP1-02 — resolved by ADR-011 (tokio from day one).
+- UQ-WP1-03 — resolved in Task 6: per-command oneshot ack; `Arc` commit counter.
+- UQ-WP1-04 — resolved in Task 5 (ADR-005).
+- UQ-WP1-05 — resolved in Task 3: full `runs` shape; plugin-invocation columns carried as JSON in the `config` column, populated by WP2.
+- UQ-WP1-06 — resolved in Task 3: `StorageError` wraps `rusqlite::Error` via `thiserror`.
+- UQ-WP1-07 — resolved in Task 2: `EntityIdError::SegmentContainsColon`.
+- UQ-WP1-08 — resolved in Task 5: refuses if `.clarion/` exists; `--force` stub.
+- UQ-WP1-09 — resolved in Task 1: Rust 2021 + stable via `rust-toolchain.toml`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /home/john/clarion && git add docs/implementation/sprint-1/ && git commit -m "$(cat <<'EOF'
+docs(sprint-1): tick WP1 sign-off and stamp L1/L2/L3 lock-ins
+
+Tier A.1 boxes ticked in signoffs.md with the WP1-close commit date.
+README.md §4 lock-in table stamped for L1/L2/L3. wp1-scaffold.md §5
+UQ resolutions recorded inline with outcome + resolving task.
+
+WP1 complete; ready for WP2 kickoff.
+EOF
+)"
+```
+
+---
+
+## Self-review summary
+
+**Spec coverage vs `wp1-scaffold.md`:**
+
+| Spec task | Plan task | Status |
+|---|---|---|
+| §6.Task 1 Workspace skeleton | Task 1 | ✓ |
+| §6.Task 2 Entity-ID assembler | Task 2 | ✓ |
+| §6.Task 3 Schema migration | Task 3 | ✓ |
+| §6.Task 4 Reader pool | Task 4 | ✓ |
+| §6.Task 5 `clarion install` + ADR-005 | Task 5 | ✓ |
+| §6.Task 6 Writer-actor | Task 6 | ✓ |
+| §6.Task 7 `clarion analyze` | Task 7 | ✓ |
+| §6.Task 8 LlmProvider stub | Task 8 | ✓ |
+| §6.Task 9 E2E smoke | Task 9 | ✓ |
+| §8 Exit criteria sign-off | Task 10 | ✓ |
+
+Every lock-in (L1/L2/L3) has a Task that lands it. Every UQ-WP1-* has a designated resolution Task. Every exit-criteria bullet has a verification step.
+
+**Type consistency spot-check:**
+
+- `EntityId` / `entity_id()` signature identical between Task 2 definition and Task 8's stub (both re-exported from `clarion-core::lib`).
+- `WriterCmd::{BeginRun,InsertEntity,CommitRun,FailRun}` — same four variants in `commands.rs` (Task 6 Step 1), `writer.rs` dispatch (Task 6 Step 2), and the test harness (Task 6 Step 4) and `analyze.rs` (Task 7 Step 1).
+- `RunStatus::{SkippedNoPlugins,Completed,Failed}` — used in `writer.rs`, `commands.rs`, `analyze.rs`, and the integration tests. Strings match migration `0001` implicit values (`skipped_no_plugins`, `completed`, `failed`, plus `running` from `BeginRun`).
+- `Writer::commits_observed` is a public `Arc` — accessed by name in Task 6 tests.
+- `DEFAULT_BATCH_SIZE = 50` and `DEFAULT_CHANNEL_CAPACITY = 256` are consistent with ADR-011.
+
+**Placeholder scan:** no `TODO`, `TBD`, `implement later`, or "Similar to Task N" in the plan body. Every code step has a complete code block; every command step has a literal command + expected output. ADR-005 body is fully written (no "flesh out later"). Task 10's sign-off bullets name each UQ's resolution verbatim.
+
+**Divergence from the spec that the executor should know about:**
+
+1. `Writer::spawn` uses `tokio::task::spawn_blocking` rather than plain `tokio::spawn`, because `rusqlite::Connection` is `!Send` across await points. Equivalent to ADR-011's "or `deadpool-sqlite`'s `Object::interact` pattern" phrasing. Documented at Task 6 Step 2.
+2. Task 7 adds a small inline `gmtime()` to avoid adding `chrono` to the workspace solely for timestamp formatting. If later WPs need richer time handling, promote `chrono` to `[workspace.dependencies]` at that time.
+3. The `guidance_sheets` view aggregates `entity_tags.tag` into a JSON array via a correlated subquery, because the detailed-design §3 SQL references a `tags` column on `entities` that doesn't exist under the normalised tag schema. This is a faithful interpretation that preserves the view's row shape; flag it to the design-doc author post-Sprint-1.
+4. `uuid` crate added as a workspace dependency in Task 7 for run-ID generation. Worth noting because the WP1 doc's §4 external-dependencies table doesn't list it — consider adding a row when updating the spec.
+5. **ADR-023 tooling baseline** adopted before any code lands. Original UQ-WP1-09's "edition 2021, fine to document and move on" framing was reopened on 2026-04-18 and re-resolved: Task 1 now ships edition 2024, workspace `[lints]` pedantic, rustfmt + clippy + deny configs, cargo-nextest, GitHub Actions CI. The justification is the retrofit cost asymmetry (cheap at zero code, expensive later). See ADR-023 and the revised UQ-WP1-09 in `wp1-scaffold.md §5`. Every subsequent task's gate is pedantic-clean + fmt-clean + nextest-green; the CI workflow proves these invariants on every PR.
+
+---
+
+**Plan complete and saved to `docs/superpowers/plans/2026-04-18-wp1-scaffold-storage.md`.**
+
+Execution approach chosen: **Subagent-Driven** (fresh subagent per task + two-stage review). Next step: dispatch the Task 1 implementer with the post-ADR-023 scope.
diff --git a/docs/superpowers/plans/2026-04-18-wp2-plugin-host.md b/docs/superpowers/plans/2026-04-18-wp2-plugin-host.md
new file mode 100644
index 00000000..628ea21b
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-18-wp2-plugin-host.md
@@ -0,0 +1,4371 @@
+# WP2 — Plugin Host + Hybrid Authority Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship the Sprint 1 walking-skeleton plugin host — JSON-RPC over Content-Length framed stdio (L4), `plugin.toml` manifest parser (L5), core-enforced minimums (L6, all four controls per ADR-021 §2), plugin discovery (L9), crash-loop breaker, and wired `clarion analyze` that spawns a mock plugin and persists entities through the WP1 writer-actor.
+
+**Architecture:** Plugin host lives inside `clarion-core` under a new `plugin/` module (per WP2 §3). Subprocesses are managed via `tokio::process::Command` with piped stdio; framing is hand-rolled LSP-style Content-Length on top of `tokio::io::AsyncRead`/`AsyncWrite` (UQ-WP2-02 resolution). ADR-021 §2d's `RLIMIT_AS` is applied inside `CommandExt::pre_exec` — the only unsafe call site in the workspace, gated by `#[allow(unsafe_code)]` with a safety comment. A fresh workspace crate `clarion-mock-plugin` supplies the fixture binary that the integration-test subprocess spawns.
+
+**Tech Stack:** Additions to the WP1 floor — `toml = "0.8"` (manifest parsing); `nix = { version = "0.28", features = ["resource"] }` (`setrlimit(RLIMIT_AS)`); `tokio` features expanded with `"process"` + `"io-util"`; `tokio-util = { version = "0.7", features = ["codec"] }` is **not** adopted (hand-rolled framing keeps the dependency surface small per UQ-WP2-02). New workspace member `crates/clarion-mock-plugin/` (test fixture binary). All ADR-023 gates (fmt / pedantic clippy / nextest / doc / deny / build dev + release) remain green on every commit.
+
+**Source spec:** `docs/implementation/sprint-1/wp2-plugin-host.md`. If this plan and the spec disagree, the spec is authoritative on *what* to build; this plan is authoritative on *how* to build it step-by-step.
+
+**ADR anchors:** ADR-002 (plugin transport JSON-RPC over Content-Length framed stdio), ADR-021 (plugin authority hybrid — declared capabilities + four core-enforced minimums), ADR-022 (core/plugin ontology boundary — reserved kinds, rule-ID namespaces, identifier grammar), ADR-003 (3-segment EntityId — reused verbatim from WP1 for identity-mismatch rejection), ADR-011 (writer-actor — reused verbatim from WP1 for entity persistence), ADR-023 (tooling baseline — applied to every WP2 commit).
+
+**Resolved UQs before starting:**
+
+- **UQ-WP2-01** — Plugin discovery: PATH scan for `clarion-plugin-*` binaries + neighboring `plugin.toml` fallback to `/share/clarion/plugins//plugin.toml`. Sprint 1 hard-codes no env var overrides; `clarion.yaml` discovery config surface is WP6. **Resolved by Task 5.**
+- **UQ-WP2-02** — JSON-RPC library: hand-rolled over `serde_json`. Walking skeleton is unidirectional unary; framing is Content-Length + `\r\n\r\n` + body; one request → one response. **Resolved by Task 2.**
+- **UQ-WP2-03** — Path jail symlink semantics: canonicalise via `std::fs::canonicalize` which follows symlinks. A symlink inside the root that resolves outside is rejected. **Resolved by Task 4.**
+- **UQ-WP2-04** — Content-Length ceiling: **8 MiB** per frame (ADR-021 §2b default; floor 1 MiB). Transport parser refuses the frame before body deserialisation; host kills plugin with SIGTERM → SIGKILL; emits `CLA-INFRA-PLUGIN-FRAME-OVERSIZE`. **Resolved by Task 4.**
+- **UQ-WP2-05** — Entity-count cap: **500,000** combined `entity + edge + finding` records per run (ADR-021 §2c default; floor 10,000). On trip: in-flight batch flushed, plugin killed, run enters partial-results; `CLA-INFRA-PLUGIN-ENTITY-CAP` emitted. Sprint 1 emits only entity notifications; the cap counts them and leaves the edge/finding path ready for WP3+. **Resolved by Task 4.**
+- **UQ-WP2-06** — prlimit on non-Linux: `#[cfg(target_os = "linux")]`-gate the `setrlimit` pre_exec; on other targets, log a one-shot tracing warning and proceed without the limit. Sprint 1 scope is Linux per WP1 §1; macOS path (ADR-021 §2d names `setrlimit(RLIMIT_AS)` on POSIX) lands with whichever sprint first adds macOS CI. **Resolved by Task 4.**
+- **UQ-WP2-07** — Plugin non-entity output: stderr is free-form and forwarded line-by-line to `tracing::info!` target `plugin::`. Stdout is JSON-RPC only. Progress notifications are deferred. **Resolved by Task 3.**
+- **UQ-WP2-08** — Plugin stdout discipline: documented in the plugin-author guide (WP3 scope); host does not enforce. A stray `print()` in a Python plugin corrupts framing → transport parse error → plugin killed → crash-loop counter ticks. **Resolved by Task 3** (doc note in module rustdoc).
+- **UQ-WP2-09** — Manifest hot-reload: Sprint 1 always re-reads on `clarion analyze`. Caching belongs to WP8 `serve`. **Resolved by Task 2** (manifest is re-parsed on every `PluginHost::spawn`).
+- **UQ-WP2-10** — Crash-loop breaker parameters: **>3 crashes in 60s** → plugin disabled for the run (`CLA-INFRA-PLUGIN-DISABLED-CRASH-LOOP`, per ADR-002 + ADR-021 Layer 3). Path-escape sub-breaker: **>10 escapes in 60s** → plugin killed (`CLA-INFRA-PLUGIN-DISABLED-PATH-ESCAPE`, per ADR-021 §2a). Both hard-coded in Sprint 1; config surface deferred to WP6. **Resolved by Task 7.**
+- **UQ-WP2-11** — Identity-mismatch rejection: host reconstructs `entity_id(plugin_id, kind, qualified_name)` and compares byte-for-byte against the plugin's returned `id`. Mismatch → drop entity, emit `CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH`, plugin stays alive. **Resolved by Task 6.**
+
+**Scope note on "unsafe_code = forbid":** WP1's workspace `[lints.rust]` sets `unsafe_code = "forbid"`. ADR-021 §2d's enforcement point is `CommandExt::pre_exec` — an unsafe API because the closure runs in the fork'd child before exec. Task 4 relaxes the workspace-level lint from `"forbid"` to `"deny"` and places a narrow `#[allow(unsafe_code)]` at the single pre_exec call site in `plugin/limits.rs` with a safety-justifying comment. No other unsafe is introduced. The workspace guarantee becomes: "unsafe is denied except at one audited call site that is the only way to express the ADR-021 §2d enforcement."
+
+**Findings catalogue (WP2-introduced rule IDs):**
+
+| Rule ID | Trigger |
+|---|---|
+| `CLA-INFRA-PLUGIN-PATH-ESCAPE` | Plugin-returned path canonicalises outside `project_root`; entity dropped, plugin alive. |
+| `CLA-INFRA-PLUGIN-DISABLED-PATH-ESCAPE` | >10 path-escapes in 60s; plugin killed. |
+| `CLA-INFRA-PLUGIN-FRAME-OVERSIZE` | Inbound Content-Length header above the 8 MiB ceiling; plugin killed. |
+| `CLA-INFRA-PLUGIN-ENTITY-CAP` | Per-run cumulative record count exceeds 500k; plugin killed, run partial. |
+| `CLA-INFRA-PLUGIN-OOM-KILLED` | Plugin exit was `WIFSIGNALED && WTERMSIG == 9` after `RLIMIT_AS` hit. |
+| `CLA-INFRA-PLUGIN-DISABLED-CRASH-LOOP` | >3 crashes in 60s; plugin disabled for the run. |
+| `CLA-INFRA-PLUGIN-UNDECLARED-KIND` | Entity emitted with `kind` not in manifest's `[ontology].entity_kinds`. |
+| `CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH` | Entity's `id` does not match `entity_id(plugin_id, kind, qualified_name)`. |
+| `CLA-INFRA-MANIFEST-MALFORMED` | Manifest fails grammar or required-field validation at `initialize`. |
+| `CLA-INFRA-MANIFEST-RESERVED-KIND` | Manifest declares a core-reserved kind (`file`, `subsystem`, `guidance`). |
+
+**Sprint 1 scope on findings emission:** Sprint 1 does NOT create a `findings` table row for these rule IDs — that path is ADR-013's scanner-ingest API and arrives in WP6. For Sprint 1, each trigger logs via `tracing::warn!(rule_id = "CLA-INFRA-...", ...)` with the offending context as fields. The strings are still authoritative — WP6 reads them when wiring the `Finding` record emission. Tests assert on the log lines via `tracing-test` or captured output, not on DB rows.
+
+---
+
+## Task 1: Workspace prep + L5 manifest parser
+
+**Files:**
+- Modify: `/home/john/clarion/Cargo.toml` (workspace deps: add `toml`, extend `tokio` features)
+- Modify: `/home/john/clarion/crates/clarion-core/Cargo.toml` (add `toml`, `serde`, `thiserror`)
+- Create: `/home/john/clarion/crates/clarion-core/src/plugin/mod.rs`
+- Create: `/home/john/clarion/crates/clarion-core/src/plugin/manifest.rs`
+- Modify: `/home/john/clarion/crates/clarion-core/src/lib.rs` (add `pub mod plugin;` re-exports)
+- Create: `/home/john/clarion/crates/clarion-core/tests/fixtures/manifest_valid.toml`
+- Create: `/home/john/clarion/crates/clarion-core/tests/fixtures/manifest_missing_name.toml`
+
+- [ ] **Step 1: Extend workspace dependencies**
+
+Modify the top `[workspace.dependencies]` block in `/home/john/clarion/Cargo.toml` — **add** two entries, **modify** the `tokio` line to include `"process"` and `"io-util"` features:
+
+```toml
+# Replace the existing tokio line with:
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util"] }
+
+# Add these new workspace deps (keep alphabetical order):
+toml = "0.8"
+```
+
+Do NOT add `nix` here — that arrives in Task 4 where it's first used.
+
+- [ ] **Step 2: Extend `clarion-core`'s Cargo.toml**
+
+Modify `/home/john/clarion/crates/clarion-core/Cargo.toml` — add `toml`, `serde`, and `thiserror` to `[dependencies]`. They may already be present from WP1 (entity_id uses serde + thiserror); add only what's missing. The end state is:
+
+```toml
+[dependencies]
+serde = { workspace = true, features = ["derive"] }
+serde_json.workspace = true
+thiserror.workspace = true
+toml.workspace = true
+```
+
+(Keep any pre-existing WP1 entries; append `toml.workspace = true` if not already there.)
+
+- [ ] **Step 3: Write the module skeleton**
+
+Create `/home/john/clarion/crates/clarion-core/src/plugin/mod.rs`:
+
+```rust
+//! Plugin host — subprocess supervision, JSON-RPC transport, manifest
+//! parsing, and ADR-021 core-enforced minimums.
+//!
+//! # Scope
+//!
+//! This module is the Clarion-side end of the plugin transport defined in
+//! [ADR-002](../../../../../docs/clarion/adr/ADR-002-plugin-transport-json-rpc.md)
+//! and the enforcement surface for
+//! [ADR-021](../../../../../docs/clarion/adr/ADR-021-plugin-authority-hybrid.md)
+//! §2a-d. The ontology boundary rules from
+//! [ADR-022](../../../../../docs/clarion/adr/ADR-022-core-plugin-ontology.md)
+//! are enforced by [`host`] against the manifest parsed by [`manifest`].
+//!
+//! # Sub-modules
+//!
+//! - [`manifest`] — `plugin.toml` parser + validator (L5).
+//! - Subsequent WP2 tasks add `transport`, `protocol`, `mock`, `jail`,
+//!   `limits`, `discovery`, `host`, `breaker`.
+
+pub mod manifest;
+
+pub use manifest::{
+    Capabilities, Manifest, ManifestError, Ontology, PluginHeader, parse_manifest,
+};
+```
+
+- [ ] **Step 4: Export from `lib.rs`**
+
+Modify `/home/john/clarion/crates/clarion-core/src/lib.rs` to declare the new module. The final file body:
+
+```rust
+//! clarion-core — domain types, identifiers, provider traits, and plugin host.
+//!
+//! WP2 introduces the `plugin` module which drives subprocess plugins
+//! via `JSON-RPC` over Content-Length framed stdio. The module's public
+//! surface is re-exported at the crate root for short import paths.
+
+pub mod entity_id;
+pub mod llm_provider;
+pub mod plugin;
+
+pub use entity_id::{EntityId, EntityIdError, entity_id};
+pub use llm_provider::{LlmProvider, NoopProvider};
+```
+
+- [ ] **Step 5: Add the valid-manifest fixture**
+
+Create `/home/john/clarion/crates/clarion-core/tests/fixtures/manifest_valid.toml`:
+
+```toml
+[plugin]
+name = "clarion-plugin-python"
+version = "0.1.0"
+protocol_version = "1.0"
+executable = "clarion-plugin-python"
+language = "python"
+extensions = ["py"]
+
+[capabilities]
+max_rss_mb = 512
+max_runtime_seconds = 300
+max_content_length_bytes = 10485760
+max_entities_per_run = 100000
+
+[ontology]
+entity_kinds = ["function", "class", "module", "decorator"]
+edge_kinds = ["imports", "calls", "decorates", "contains"]
+rule_id_prefix = "CLA-PY-"
+ontology_version = "0.1.0"
+```
+
+- [ ] **Step 6: Add the missing-name fixture**
+
+Create `/home/john/clarion/crates/clarion-core/tests/fixtures/manifest_missing_name.toml`:
+
+```toml
+[plugin]
+version = "0.1.0"
+protocol_version = "1.0"
+executable = "clarion-plugin-python"
+language = "python"
+extensions = ["py"]
+
+[capabilities]
+max_rss_mb = 512
+max_runtime_seconds = 300
+max_content_length_bytes = 10485760
+max_entities_per_run = 100000
+
+[ontology]
+entity_kinds = ["function"]
+edge_kinds = ["imports"]
+rule_id_prefix = "CLA-PY-"
+ontology_version = "0.1.0"
+```
+
+- [ ] **Step 7: Write the failing tests**
+
+Create `/home/john/clarion/crates/clarion-core/src/plugin/manifest.rs` with the test skeleton first (TDD — tests before implementation):
+
+```rust
+//! `plugin.toml` parser + validator (L5).
+//!
+//! Parses the manifest shape locked by WP2 §L5 and validates against
+//! [ADR-022](../../../../../docs/clarion/adr/ADR-022-core-plugin-ontology.md):
+//! plugin `name` must match the identifier grammar; entity kinds cannot
+//! shadow the core-reserved set (`file`, `subsystem`, `guidance`); rule-ID
+//! prefix must be `CLA--` and end with `-`.
+//!
+//! # Sprint 1 scope note
+//!
+//! The manifest is re-parsed on every `PluginHost::spawn` (UQ-WP2-09).
+//! Caching belongs to the `serve` path in WP8.
+
+use serde::Deserialize;
+use thiserror::Error;
+
+// ===== types defined in Step 8 below =====
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const VALID: &[u8] = include_bytes!("../../tests/fixtures/manifest_valid.toml");
+    const MISSING_NAME: &[u8] =
+        include_bytes!("../../tests/fixtures/manifest_missing_name.toml");
+
+    #[test]
+    fn parses_valid_manifest() {
+        let m = parse_manifest(VALID).expect("valid fixture must parse");
+        assert_eq!(m.plugin.name, "clarion-plugin-python");
+        assert_eq!(m.plugin.version, "0.1.0");
+        assert_eq!(m.plugin.protocol_version, "1.0");
+        assert_eq!(m.plugin.executable, "clarion-plugin-python");
+        assert_eq!(m.plugin.language, "python");
+        assert_eq!(m.plugin.extensions, vec!["py".to_string()]);
+        assert_eq!(m.capabilities.max_rss_mb, 512);
+        assert_eq!(m.capabilities.max_runtime_seconds, 300);
+        assert_eq!(m.capabilities.max_content_length_bytes, 10_485_760);
+        assert_eq!(m.capabilities.max_entities_per_run, 100_000);
+        assert_eq!(
+            m.ontology.entity_kinds,
+            vec!["function", "class", "module", "decorator"]
+        );
+        assert_eq!(
+            m.ontology.edge_kinds,
+            vec!["imports", "calls", "decorates", "contains"]
+        );
+        assert_eq!(m.ontology.rule_id_prefix, "CLA-PY-");
+        assert_eq!(m.ontology.ontology_version, "0.1.0");
+    }
+
+    #[test]
+    fn rejects_missing_name() {
+        let err = parse_manifest(MISSING_NAME).expect_err("missing name must fail");
+        match err {
+            ManifestError::Toml(_) => {}
+            other => panic!("expected Toml deserialize error, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn rejects_zero_rss() {
+        let input = br#"
+[plugin]
+name = "clarion-plugin-x"
+version = "0.1.0"
+protocol_version = "1.0"
+executable = "clarion-plugin-x"
+language = "x"
+extensions = ["x"]
+
+[capabilities]
+max_rss_mb = 0
+max_runtime_seconds = 300
+max_content_length_bytes = 10485760
+max_entities_per_run = 100000
+
+[ontology]
+entity_kinds = ["function"]
+edge_kinds = ["imports"]
+rule_id_prefix = "CLA-X-"
+ontology_version = "0.1.0"
+"#;
+        let err = parse_manifest(input).expect_err("zero RSS must fail");
+        assert!(
+            matches!(err, ManifestError::InvalidCapability { ref field, .. } if field == &"max_rss_mb"),
+            "unexpected error: {err:?}"
+        );
+    }
+
+    #[test]
+    fn rejects_empty_entity_kinds() {
+        let input = br#"
+[plugin]
+name = "clarion-plugin-x"
+version = "0.1.0"
+protocol_version = "1.0"
+executable = "clarion-plugin-x"
+language = "x"
+extensions = ["x"]
+
+[capabilities]
+max_rss_mb = 512
+max_runtime_seconds = 300
+max_content_length_bytes = 10485760
+max_entities_per_run = 100000
+
+[ontology]
+entity_kinds = []
+edge_kinds = ["imports"]
+rule_id_prefix = "CLA-X-"
+ontology_version = "0.1.0"
+"#;
+        let err = parse_manifest(input).expect_err("empty entity_kinds must fail");
+        assert!(
+            matches!(err, ManifestError::EmptyOntologyField { field: "entity_kinds" }),
+            "unexpected error: {err:?}"
+        );
+    }
+
+    #[test]
+    fn rejects_rule_id_prefix_without_trailing_dash() {
+        let input = br#"
+[plugin]
+name = "clarion-plugin-x"
+version = "0.1.0"
+protocol_version = "1.0"
+executable = "clarion-plugin-x"
+language = "x"
+extensions = ["x"]
+
+[capabilities]
+max_rss_mb = 512
+max_runtime_seconds = 300
+max_content_length_bytes = 10485760
+max_entities_per_run = 100000
+
+[ontology]
+entity_kinds = ["function"]
+edge_kinds = ["imports"]
+rule_id_prefix = "CLA-X"
+ontology_version = "0.1.0"
+"#;
+        let err = parse_manifest(input).expect_err("prefix without '-' must fail");
+        assert!(
+            matches!(err, ManifestError::RuleIdPrefixFormat { .. }),
+            "unexpected error: {err:?}"
+        );
+    }
+
+    #[test]
+    fn rejects_reserved_entity_kind_file() {
+        let input = br#"
+[plugin]
+name = "clarion-plugin-x"
+version = "0.1.0"
+protocol_version = "1.0"
+executable = "clarion-plugin-x"
+language = "x"
+extensions = ["x"]
+
+[capabilities]
+max_rss_mb = 512
+max_runtime_seconds = 300
+max_content_length_bytes = 10485760
+max_entities_per_run = 100000
+
+[ontology]
+entity_kinds = ["function", "file"]
+edge_kinds = ["imports"]
+rule_id_prefix = "CLA-X-"
+ontology_version = "0.1.0"
+"#;
+        let err = parse_manifest(input).expect_err("reserved kind must fail");
+        assert!(
+            matches!(err, ManifestError::ReservedKind { ref kind } if kind == "file"),
+            "unexpected error: {err:?}"
+        );
+    }
+
+    #[test]
+    fn rejects_plugin_name_uppercase() {
+        let input = br#"
+[plugin]
+name = "Clarion-Plugin-X"
+version = "0.1.0"
+protocol_version = "1.0"
+executable = "clarion-plugin-x"
+language = "x"
+extensions = ["x"]
+
+[capabilities]
+max_rss_mb = 512
+max_runtime_seconds = 300
+max_content_length_bytes = 10485760
+max_entities_per_run = 100000
+
+[ontology]
+entity_kinds = ["function"]
+edge_kinds = ["imports"]
+rule_id_prefix = "CLA-X-"
+ontology_version = "0.1.0"
+"#;
+        let err = parse_manifest(input).expect_err("uppercase name must fail");
+        assert!(
+            matches!(err, ManifestError::InvalidPluginName { .. }),
+            "unexpected error: {err:?}"
+        );
+    }
+
+    #[test]
+    fn rejects_empty_extensions() {
+        let input = br#"
+[plugin]
+name = "clarion-plugin-x"
+version = "0.1.0"
+protocol_version = "1.0"
+executable = "clarion-plugin-x"
+language = "x"
+extensions = []
+
+[capabilities]
+max_rss_mb = 512
+max_runtime_seconds = 300
+max_content_length_bytes = 10485760
+max_entities_per_run = 100000
+
+[ontology]
+entity_kinds = ["function"]
+edge_kinds = ["imports"]
+rule_id_prefix = "CLA-X-"
+ontology_version = "0.1.0"
+"#;
+        let err = parse_manifest(input).expect_err("empty extensions must fail");
+        assert!(
+            matches!(err, ManifestError::EmptyExtensions),
+            "unexpected error: {err:?}"
+        );
+    }
+}
+```
+
+- [ ] **Step 8: Run tests; expect failure (no types defined)**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-core manifest --no-tests=pass 2>&1 | head -40
+```
+
+Expected: compile error `cannot find type 'Manifest'` (and several similar). That's the "red" state.
+
+- [ ] **Step 9: Implement the types and `parse_manifest`**
+
+Replace the `// ===== types defined in Step 8 below =====` marker in `plugin/manifest.rs` with:
+
+```rust
+/// The top-level `plugin.toml` structure.
+#[derive(Debug, Clone, Deserialize)]
+pub struct Manifest {
+    pub plugin: PluginHeader,
+    pub capabilities: Capabilities,
+    pub ontology: Ontology,
+}
+
+/// The `[plugin]` table.
+#[derive(Debug, Clone, Deserialize)]
+pub struct PluginHeader {
+    pub name: String,
+    pub version: String,
+    pub protocol_version: String,
+    pub executable: String,
+    pub language: String,
+    pub extensions: Vec,
+}
+
+/// The `[capabilities]` table. Values are the plugin's own declared
+/// envelope; the core's ADR-021 §2 ceilings apply independently — effective
+/// limit is `min(manifest, core)`.
+#[derive(Debug, Clone, Deserialize)]
+pub struct Capabilities {
+    pub max_rss_mb: u64,
+    pub max_runtime_seconds: u64,
+    pub max_content_length_bytes: u64,
+    pub max_entities_per_run: u64,
+}
+
+/// The `[ontology]` table.
+#[derive(Debug, Clone, Deserialize)]
+pub struct Ontology {
+    pub entity_kinds: Vec,
+    pub edge_kinds: Vec,
+    pub rule_id_prefix: String,
+    pub ontology_version: String,
+}
+
+/// Errors returned by [`parse_manifest`].
+#[derive(Debug, Error)]
+pub enum ManifestError {
+    #[error("manifest is not valid UTF-8: {0}")]
+    Utf8(#[from] std::str::Utf8Error),
+
+    #[error("manifest `TOML` parse/deserialize error: {0}")]
+    Toml(#[from] toml::de::Error),
+
+    #[error(
+        "[plugin].name {value:?} violates ADR-022 grammar \
+         (identifier `[a-z][a-z0-9_-]*`, must start with `clarion-plugin-`)"
+    )]
+    InvalidPluginName { value: String },
+
+    #[error("[plugin].extensions must declare at least one extension")]
+    EmptyExtensions,
+
+    #[error(
+        "[capabilities].{field} invariant failed: value {value} outside \
+         permitted range (must be > 0)"
+    )]
+    InvalidCapability { field: &'static str, value: u64 },
+
+    #[error("[ontology].{field} must declare at least one entry")]
+    EmptyOntologyField { field: &'static str },
+
+    #[error(
+        "[ontology].rule_id_prefix {value:?} must start with `CLA-` and end \
+         with `-` (ADR-022 rule-ID namespace contract)"
+    )]
+    RuleIdPrefixFormat { value: String },
+
+    #[error(
+        "[ontology].entity_kinds contains core-reserved kind {kind:?} \
+         (per ADR-022: `file`, `subsystem`, `guidance` are core-only)"
+    )]
+    ReservedKind { kind: String },
+}
+
+/// Core-reserved entity kinds per
+/// [ADR-022](../../../../../docs/clarion/adr/ADR-022-core-plugin-ontology.md).
+/// These are produced by core-owned algorithms (file-discovery, Leiden
+/// clustering, guidance composition). A plugin declaring any of them in
+/// its `entity_kinds` list is rejected at manifest parse.
+const RESERVED_ENTITY_KINDS: &[&str] = &["file", "subsystem", "guidance"];
+
+/// Parse a `plugin.toml` payload and validate it against ADR-022 + the L5
+/// schema locked in WP2 §L5.
+///
+/// # Errors
+///
+/// Returns a [`ManifestError`] variant for any deserialization or
+/// validation failure. The error message names the failing field so
+/// plugin authors can fix the manifest without consulting the ADR.
+pub fn parse_manifest(bytes: &[u8]) -> Result {
+    let text = std::str::from_utf8(bytes)?;
+    let manifest: Manifest = toml::from_str(text)?;
+    validate(&manifest)?;
+    Ok(manifest)
+}
+
+fn validate(m: &Manifest) -> Result<(), ManifestError> {
+    validate_plugin_name(&m.plugin.name)?;
+    if m.plugin.extensions.is_empty() {
+        return Err(ManifestError::EmptyExtensions);
+    }
+    validate_capability("max_rss_mb", m.capabilities.max_rss_mb)?;
+    validate_capability("max_runtime_seconds", m.capabilities.max_runtime_seconds)?;
+    validate_capability(
+        "max_content_length_bytes",
+        m.capabilities.max_content_length_bytes,
+    )?;
+    validate_capability(
+        "max_entities_per_run",
+        m.capabilities.max_entities_per_run,
+    )?;
+    if m.ontology.entity_kinds.is_empty() {
+        return Err(ManifestError::EmptyOntologyField {
+            field: "entity_kinds",
+        });
+    }
+    if m.ontology.edge_kinds.is_empty() {
+        return Err(ManifestError::EmptyOntologyField {
+            field: "edge_kinds",
+        });
+    }
+    validate_rule_id_prefix(&m.ontology.rule_id_prefix)?;
+    for k in &m.ontology.entity_kinds {
+        if RESERVED_ENTITY_KINDS.contains(&k.as_str()) {
+            return Err(ManifestError::ReservedKind { kind: k.clone() });
+        }
+    }
+    Ok(())
+}
+
+fn validate_plugin_name(name: &str) -> Result<(), ManifestError> {
+    // ADR-022 grammar for plugin identifiers is `[a-z][a-z0-9_]*`. WP2 §L9
+    // requires PATH-installable binaries prefixed `clarion-plugin-`, which
+    // broadens the grammar to include `-`. We accept the broader form for
+    // the manifest `name` (which is also the binary name) and narrow it
+    // back to `[a-z][a-z0-9_]*` when deriving `plugin_id` for `EntityId`
+    // assembly. The narrowing is a Task 6 concern; Task 1 only validates
+    // the broader binary-name grammar.
+    let mut chars = name.chars();
+    let Some(first) = chars.next() else {
+        return Err(ManifestError::InvalidPluginName {
+            value: name.to_owned(),
+        });
+    };
+    if !first.is_ascii_lowercase() {
+        return Err(ManifestError::InvalidPluginName {
+            value: name.to_owned(),
+        });
+    }
+    for c in chars {
+        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') {
+            return Err(ManifestError::InvalidPluginName {
+                value: name.to_owned(),
+            });
+        }
+    }
+    if !name.starts_with("clarion-plugin-") {
+        return Err(ManifestError::InvalidPluginName {
+            value: name.to_owned(),
+        });
+    }
+    Ok(())
+}
+
+fn validate_capability(field: &'static str, value: u64) -> Result<(), ManifestError> {
+    if value == 0 {
+        return Err(ManifestError::InvalidCapability { field, value });
+    }
+    Ok(())
+}
+
+fn validate_rule_id_prefix(value: &str) -> Result<(), ManifestError> {
+    if !value.starts_with("CLA-") || !value.ends_with('-') || value.len() < 6 {
+        return Err(ManifestError::RuleIdPrefixFormat {
+            value: value.to_owned(),
+        });
+    }
+    Ok(())
+}
+```
+
+- [ ] **Step 10: Run tests; expect pass**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-core manifest --no-tests=pass
+```
+
+Expected: 8 tests pass (`parses_valid_manifest`, `rejects_missing_name`, `rejects_zero_rss`, `rejects_empty_entity_kinds`, `rejects_rule_id_prefix_without_trailing_dash`, `rejects_reserved_entity_kind_file`, `rejects_plugin_name_uppercase`, `rejects_empty_extensions`).
+
+- [ ] **Step 11: Full ADR-023 gate sweep**
+
+```bash
+cd /home/john/clarion && cargo fmt --all -- --check
+cd /home/john/clarion && cargo clippy --workspace --all-targets --all-features -- -D warnings
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo doc --workspace --no-deps --all-features
+cd /home/john/clarion && cargo deny check
+```
+
+All five must exit 0. Pedantic expectations: `doc_markdown` may flag bare `TOML` / `JSON-RPC` tokens — backtick them (`` `TOML` ``, `` `JSON-RPC` ``) in the doc comments above before re-running clippy.
+
+- [ ] **Step 12: Commit**
+
+```bash
+cd /home/john/clarion && git add Cargo.toml crates/clarion-core/ && git commit -m "$(cat <<'EOF'
+feat(wp2): L5 plugin.toml manifest parser and validator
+
+Adds clarion-core::plugin::{manifest,mod} with parse_manifest(&[u8]) ->
+Result. Validates against ADR-022: plugin name
+grammar [a-z][a-z0-9_-]* with required `clarion-plugin-` prefix; rule-ID
+prefix must be CLA-- and end with `-`; entity_kinds cannot shadow
+the core-reserved set (file, subsystem, guidance); required fields present;
+capabilities strictly positive.
+
+Workspace: toml = "0.8" added; tokio features extended with "process" +
+"io-util" (no runtime effect until Task 2). Fixtures live under
+crates/clarion-core/tests/fixtures/ and are include_bytes!-loaded into the
+unit tests to keep the positive-path assertion a single source of truth.
+
+8 tests: positive, 7 negatives covering every ManifestError variant.
+EOF
+)"
+```
+
+---
+
+## Task 2: L4 JSON-RPC Content-Length transport
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-core/src/plugin/transport.rs`
+- Create: `/home/john/clarion/crates/clarion-core/src/plugin/protocol.rs`
+- Modify: `/home/john/clarion/crates/clarion-core/src/plugin/mod.rs` (add module decls + re-exports)
+
+- [ ] **Step 1: Extend `plugin/mod.rs`**
+
+Replace the previous `plugin/mod.rs` body with:
+
+```rust
+//! Plugin host — subprocess supervision, JSON-RPC transport, manifest
+//! parsing, and ADR-021 core-enforced minimums.
+//!
+//! # Sub-modules
+//!
+//! - [`manifest`] — `plugin.toml` parser + validator (L5).
+//! - [`transport`] — Content-Length framed `JSON-RPC` codec (L4).
+//! - [`protocol`] — typed request/response shapes for every L4 method.
+
+pub mod manifest;
+pub mod protocol;
+pub mod transport;
+
+pub use manifest::{
+    Capabilities, Manifest, ManifestError, Ontology, PluginHeader, parse_manifest,
+};
+pub use protocol::{
+    AnalyzeFileParams, AnalyzeFileResult, InitializeParams, InitializeResult, JsonRpcError,
+    JsonRpcErrorCode, JsonRpcRequest, JsonRpcResponse, Method, PluginEntity, PluginSource,
+    ShutdownParams,
+};
+pub use transport::{Frame, TransportError, read_frame, write_frame};
+```
+
+- [ ] **Step 2: Write `protocol.rs`**
+
+Create `/home/john/clarion/crates/clarion-core/src/plugin/protocol.rs`:
+
+```rust
+//! Typed `JSON-RPC` 2.0 shapes for the L4 method set.
+//!
+//! The walking-skeleton method set (per WP2 §L4 + ADR-002):
+//!
+//! | Method         | Direction            | Purpose                                  |
+//! |----------------|----------------------|------------------------------------------|
+//! | `initialize`   | Core → plugin        | Handshake; exchange protocol + manifest  |
+//! | `initialized`  | Core → plugin (note) | Start signal                             |
+//! | `analyze_file` | Core → plugin        | Per-file entity extraction               |
+//! | `shutdown`     | Core → plugin        | Graceful stop                            |
+//! | `exit`         | Core → plugin (note) | Forceful termination notification        |
+//!
+//! Error codes follow the JSON-RPC 2.0 spec plus Clarion-reserved
+//! [-32000, -32099] range for transport/host-side violations.
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+/// `JSON-RPC` 2.0 request envelope. `id` is `Option` because
+/// notifications (`initialized`, `exit`) omit it.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct JsonRpcRequest {
+    pub jsonrpc: String,
+    pub method: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub params: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub id: Option,
+}
+
+impl JsonRpcRequest {
+    /// Build a request carrying the given id, method, and typed params.
+    pub fn new(id: u64, method: Method, params: &P) -> Result {
+        Ok(Self {
+            jsonrpc: "2.0".to_owned(),
+            method: method.as_str().to_owned(),
+            params: Some(serde_json::to_value(params)?),
+            id: Some(Value::from(id)),
+        })
+    }
+
+    /// Build a notification (no id, no response expected).
+    pub fn notification(method: Method, params: &P) -> Result {
+        Ok(Self {
+            jsonrpc: "2.0".to_owned(),
+            method: method.as_str().to_owned(),
+            params: Some(serde_json::to_value(params)?),
+            id: None,
+        })
+    }
+}
+
+/// `JSON-RPC` 2.0 response envelope. Exactly one of `result`/`error` is set.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct JsonRpcResponse {
+    pub jsonrpc: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub result: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub error: Option,
+    pub id: Value,
+}
+
+/// `JSON-RPC` 2.0 error shape.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct JsonRpcError {
+    pub code: i32,
+    pub message: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub data: Option,
+}
+
+/// Error codes used by the Clarion host/plugin protocol. The standard
+/// `JSON-RPC` 2.0 codes are included for completeness.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[repr(i32)]
+pub enum JsonRpcErrorCode {
+    ParseError = -32_700,
+    InvalidRequest = -32_600,
+    MethodNotFound = -32_601,
+    InvalidParams = -32_602,
+    InternalError = -32_603,
+    /// Clarion-reserved: plugin refused the manifest handshake.
+    ManifestRejected = -32_000,
+    /// Clarion-reserved: plugin reports an analysis-level error.
+    AnalyzeFailed = -32_001,
+}
+
+/// Canonical method name enum. Using `Method::AnalyzeFile.as_str()` at
+/// the call site keeps the wire literal in one place.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Method {
+    Initialize,
+    Initialized,
+    AnalyzeFile,
+    Shutdown,
+    Exit,
+}
+
+impl Method {
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Self::Initialize => "initialize",
+            Self::Initialized => "initialized",
+            Self::AnalyzeFile => "analyze_file",
+            Self::Shutdown => "shutdown",
+            Self::Exit => "exit",
+        }
+    }
+}
+
+/// `initialize` request params — the core tells the plugin the protocol
+/// version it expects and echoes the manifest name/version the core parsed.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InitializeParams {
+    pub protocol_version: String,
+    pub plugin_name: String,
+    pub plugin_version: String,
+    pub project_root: String,
+}
+
+/// `initialize` result — the plugin confirms the protocol version and
+/// reports any handshake-time capabilities. Sprint 1 only carries the
+/// echo fields; WP3+ extends this.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InitializeResult {
+    pub protocol_version: String,
+    pub plugin_name: String,
+    pub plugin_version: String,
+}
+
+/// `analyze_file` request params — the core passes one file per request.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AnalyzeFileParams {
+    pub path: String,
+}
+
+/// `analyze_file` result — the plugin returns a flat list of entities.
+/// Edges and findings arrive via separate notifications in WP3+; Sprint 1
+/// only deals with entities.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AnalyzeFileResult {
+    pub entities: Vec,
+}
+
+/// Shutdown params — empty. Kept as a struct so the typed-params call
+/// sites don't have to special-case `()`.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct ShutdownParams {}
+
+/// Entity as emitted by the plugin. The host validates this shape before
+/// translating it into [`clarion_storage::EntityRecord`]:
+///
+/// - `id` must equal `entity_id(plugin_id, kind, qualified_name)` (ADR-003
+///   + ADR-022; enforced by [`super::host`] per UQ-WP2-11).
+/// - `kind` must appear in the manifest's `[ontology].entity_kinds`
+///   (ADR-022; enforced by [`super::host`]).
+/// - `source.file_path` must canonicalise inside `project_root`
+///   (ADR-021 §2a; enforced by [`super::host`]).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PluginEntity {
+    pub id: String,
+    pub plugin_id: String,
+    pub kind: String,
+    pub qualified_name: String,
+    pub short_name: String,
+    pub source: PluginSource,
+    #[serde(default)]
+    pub properties: Value,
+    #[serde(default)]
+    pub content_hash: Option,
+    #[serde(default)]
+    pub parent_id: Option,
+}
+
+/// Source range an entity came from.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PluginSource {
+    pub file_path: String,
+    pub byte_start: Option,
+    pub byte_end: Option,
+    pub line_start: Option,
+    pub line_end: Option,
+}
+```
+
+- [ ] **Step 3: Write `transport.rs` — tests first**
+
+Create `/home/john/clarion/crates/clarion-core/src/plugin/transport.rs`:
+
+```rust
+//! Content-Length framed `JSON-RPC` codec (L4).
+//!
+//! Exactly the LSP framing shape:
+//!
+//! ```text
+//! Content-Length: \r\n
+//! \r\n
+//! 
+//! ```
+//!
+//! Headers after `Content-Length` are tolerated and ignored (matches LSP
+//! behaviour — future extensions may add `Content-Type`). Header lines end
+//! with `\r\n`; the blank line `\r\n` separates headers from body.
+//!
+//! Per ADR-021 §2b, every inbound frame's Content-Length header is checked
+//! against the ceiling **before** the body is consumed: a too-large frame
+//! returns [`TransportError::FrameTooLarge`] without reading or buffering
+//! the payload.
+
+use thiserror::Error;
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
+
+/// A decoded frame. The body is raw JSON bytes — callers deserialise into
+/// [`crate::plugin::protocol`] types.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Frame {
+    pub body: Vec,
+}
+
+#[derive(Debug, Error)]
+pub enum TransportError {
+    #[error("I/O error in transport: {0}")]
+    Io(#[from] std::io::Error),
+
+    #[error("reached EOF while reading header")]
+    UnexpectedEofInHeader,
+
+    #[error("reached EOF while reading body (expected {expected} bytes, got {got})")]
+    UnexpectedEofInBody { expected: usize, got: usize },
+
+    #[error("header not valid UTF-8: {0}")]
+    HeaderNotUtf8(#[from] std::str::Utf8Error),
+
+    #[error("missing Content-Length header")]
+    MissingContentLength,
+
+    #[error("malformed Content-Length header: {raw:?}")]
+    MalformedContentLength { raw: String },
+
+    #[error(
+        "frame size {observed} exceeds ADR-021 §2b ceiling {ceiling} \
+         (rule-id CLA-INFRA-PLUGIN-FRAME-OVERSIZE)"
+    )]
+    FrameTooLarge { observed: usize, ceiling: usize },
+}
+
+/// Read exactly one frame from `reader`. Returns an error if the frame's
+/// declared Content-Length exceeds `ceiling_bytes` — in that case, the
+/// body is **not** consumed from the reader, so the caller may close the
+/// subprocess without draining the oversize payload.
+///
+/// # Errors
+///
+/// Returns a [`TransportError`] variant for each failure mode documented
+/// on the enum.
+pub async fn read_frame(reader: &mut R, ceiling_bytes: usize) -> Result
+where
+    R: AsyncRead + Unpin,
+{
+    let content_length = read_content_length(reader).await?;
+    if content_length > ceiling_bytes {
+        return Err(TransportError::FrameTooLarge {
+            observed: content_length,
+            ceiling: ceiling_bytes,
+        });
+    }
+    let mut body = vec![0u8; content_length];
+    let mut read = 0;
+    while read < content_length {
+        let n = reader.read(&mut body[read..]).await?;
+        if n == 0 {
+            return Err(TransportError::UnexpectedEofInBody {
+                expected: content_length,
+                got: read,
+            });
+        }
+        read += n;
+    }
+    Ok(Frame { body })
+}
+
+/// Write a frame: `Content-Length: N\r\n\r\n`.
+///
+/// # Errors
+///
+/// Returns [`TransportError::Io`] on any underlying write failure.
+pub async fn write_frame(writer: &mut W, body: &[u8]) -> Result<(), TransportError>
+where
+    W: AsyncWrite + Unpin,
+{
+    let header = format!("Content-Length: {}\r\n\r\n", body.len());
+    writer.write_all(header.as_bytes()).await?;
+    writer.write_all(body).await?;
+    writer.flush().await?;
+    Ok(())
+}
+
+async fn read_content_length(reader: &mut R) -> Result
+where
+    R: AsyncRead + Unpin,
+{
+    let mut content_length: Option = None;
+    loop {
+        let line = read_line(reader).await?;
+        if line.is_empty() {
+            break;
+        }
+        let line_str = std::str::from_utf8(&line)?;
+        if let Some(value) = line_str.strip_prefix("Content-Length:") {
+            let trimmed = value.trim();
+            content_length = Some(trimmed.parse::().map_err(|_| {
+                TransportError::MalformedContentLength {
+                    raw: trimmed.to_owned(),
+                }
+            })?);
+        }
+        // Other headers (Content-Type, future additions) are tolerated
+        // and ignored per LSP behaviour.
+    }
+    content_length.ok_or(TransportError::MissingContentLength)
+}
+
+/// Read one `\r\n`-terminated line, returning the bytes **before** the
+/// `\r\n`. Returns an empty `Vec` when the line is just `\r\n` (end of
+/// headers).
+async fn read_line(reader: &mut R) -> Result, TransportError>
+where
+    R: AsyncRead + Unpin,
+{
+    let mut out: Vec = Vec::with_capacity(64);
+    let mut prev: u8 = 0;
+    loop {
+        let mut b = [0u8; 1];
+        let n = reader.read(&mut b).await?;
+        if n == 0 {
+            return Err(TransportError::UnexpectedEofInHeader);
+        }
+        if prev == b'\r' && b[0] == b'\n' {
+            // Strip the trailing '\r' we wrote a step earlier.
+            out.pop();
+            return Ok(out);
+        }
+        out.push(b[0]);
+        prev = b[0];
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use tokio::io::duplex;
+
+    const CEILING_DEFAULT: usize = 8 * 1024 * 1024;
+
+    #[tokio::test]
+    async fn round_trip_empty_object() {
+        let (mut a, mut b) = duplex(4096);
+        let body = br#"{}"#;
+        write_frame(&mut a, body).await.unwrap();
+        drop(a);
+        let frame = read_frame(&mut b, CEILING_DEFAULT).await.unwrap();
+        assert_eq!(frame.body, body);
+    }
+
+    #[tokio::test]
+    async fn round_trip_jsonrpc_request() {
+        let (mut a, mut b) = duplex(4096);
+        let body =
+            br#"{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}"#;
+        write_frame(&mut a, body).await.unwrap();
+        drop(a);
+        let frame = read_frame(&mut b, CEILING_DEFAULT).await.unwrap();
+        assert_eq!(frame.body, body);
+    }
+
+    #[tokio::test]
+    async fn two_frames_back_to_back() {
+        let (mut a, mut b) = duplex(4096);
+        write_frame(&mut a, br#"{"id":1}"#).await.unwrap();
+        write_frame(&mut a, br#"{"id":2}"#).await.unwrap();
+        drop(a);
+        let f1 = read_frame(&mut b, CEILING_DEFAULT).await.unwrap();
+        let f2 = read_frame(&mut b, CEILING_DEFAULT).await.unwrap();
+        assert_eq!(f1.body, br#"{"id":1}"#);
+        assert_eq!(f2.body, br#"{"id":2}"#);
+    }
+
+    #[tokio::test]
+    async fn refuses_frame_above_ceiling_without_consuming_body() {
+        let (mut a, mut b) = duplex(4096);
+        // Declare 1024 bytes but the ceiling is 128 — should fail fast.
+        let body = vec![b'x'; 1024];
+        write_frame(&mut a, &body).await.unwrap();
+        drop(a);
+        let err = read_frame(&mut b, 128).await.unwrap_err();
+        match err {
+            TransportError::FrameTooLarge { observed, ceiling } => {
+                assert_eq!(observed, 1024);
+                assert_eq!(ceiling, 128);
+            }
+            other => panic!("expected FrameTooLarge, got {other:?}"),
+        }
+    }
+
+    #[tokio::test]
+    async fn missing_content_length_header_rejected() {
+        let (mut a, mut b) = duplex(4096);
+        a.write_all(b"Content-Type: application/json\r\n\r\n{}")
+            .await
+            .unwrap();
+        drop(a);
+        let err = read_frame(&mut b, CEILING_DEFAULT).await.unwrap_err();
+        assert!(matches!(err, TransportError::MissingContentLength), "got {err:?}");
+    }
+
+    #[tokio::test]
+    async fn malformed_content_length_rejected() {
+        let (mut a, mut b) = duplex(4096);
+        a.write_all(b"Content-Length: not-a-number\r\n\r\n{}")
+            .await
+            .unwrap();
+        drop(a);
+        let err = read_frame(&mut b, CEILING_DEFAULT).await.unwrap_err();
+        assert!(
+            matches!(err, TransportError::MalformedContentLength { .. }),
+            "got {err:?}"
+        );
+    }
+
+    #[tokio::test]
+    async fn eof_mid_body_returns_unexpected_eof_in_body() {
+        let (mut a, mut b) = duplex(4096);
+        // Declare 8 bytes then provide only 3.
+        a.write_all(b"Content-Length: 8\r\n\r\nabc").await.unwrap();
+        drop(a);
+        let err = read_frame(&mut b, CEILING_DEFAULT).await.unwrap_err();
+        match err {
+            TransportError::UnexpectedEofInBody { expected, got } => {
+                assert_eq!(expected, 8);
+                assert_eq!(got, 3);
+            }
+            other => panic!("unexpected: {other:?}"),
+        }
+    }
+
+    #[tokio::test]
+    async fn tolerates_unknown_headers_before_content_length() {
+        let (mut a, mut b) = duplex(4096);
+        a.write_all(b"X-Future-Header: whatever\r\nContent-Length: 2\r\n\r\n{}")
+            .await
+            .unwrap();
+        drop(a);
+        let frame = read_frame(&mut b, CEILING_DEFAULT).await.unwrap();
+        assert_eq!(frame.body, b"{}");
+    }
+}
+```
+
+- [ ] **Step 4: Add `tokio/macros` + `tokio/io-util` to `clarion-core`'s dev-dependencies**
+
+Modify `/home/john/clarion/crates/clarion-core/Cargo.toml` to ensure `tokio` (with `macros`, `io-util`, `rt`) is available under `[dev-dependencies]` for the `#[tokio::test]` attribute used above:
+
+```toml
+[dev-dependencies]
+tokio = { workspace = true, features = ["macros", "io-util", "rt", "rt-multi-thread"] }
+```
+
+(The workspace-level `tokio` already enables these; dev-dependencies inherit features additively.)
+
+- [ ] **Step 5: Run the transport tests**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-core transport --no-tests=pass
+```
+
+Expected: 7 tests pass. If any fail with `doc_markdown`-style clippy complaints during compile, backtick `LSP` / `JSON-RPC` / `Content-Length` / `UTF-8` tokens in the module-level doc comments.
+
+- [ ] **Step 6: Full ADR-023 gate sweep**
+
+```bash
+cd /home/john/clarion && cargo fmt --all -- --check
+cd /home/john/clarion && cargo clippy --workspace --all-targets --all-features -- -D warnings
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo doc --workspace --no-deps --all-features
+cd /home/john/clarion && cargo deny check
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-core/ && git commit -m "$(cat <<'EOF'
+feat(wp2): L4 JSON-RPC Content-Length transport + typed protocol shapes
+
+plugin/transport.rs implements read_frame/write_frame over any
+AsyncRead/AsyncWrite pair. Framing is LSP-identical: Content-Length
+header + \r\n\r\n + body. Unknown headers tolerated. Ceiling enforced
+BEFORE body read — a too-large frame returns FrameTooLarge without
+consuming the payload (ADR-021 §2b enforcement point).
+
+plugin/protocol.rs defines the JSON-RPC 2.0 envelope plus typed params
+and results for every L4 method: initialize, initialized, analyze_file,
+shutdown, exit. PluginEntity carries the on-wire shape the host
+validates before translating to clarion_storage::EntityRecord in Task 6.
+
+7 transport tests: round-trip empty object, round-trip real JSON-RPC
+request, two frames back-to-back, ceiling refused before body, missing
+Content-Length, malformed Content-Length, EOF mid-body, unknown
+headers tolerated.
+EOF
+)"
+```
+
+---
+
+## Task 3: In-process mock plugin test harness
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-core/src/plugin/mock.rs`
+- Modify: `/home/john/clarion/crates/clarion-core/src/plugin/mod.rs` (add `mock` module, re-export `MockPlugin` + variant enum under `#[cfg(any(test, feature = "mock"))]` — Sprint 1 uses plain `#[cfg(test)]`)
+
+- [ ] **Step 1: Extend `plugin/mod.rs`**
+
+Replace `plugin/mod.rs` with:
+
+```rust
+//! Plugin host — subprocess supervision, `JSON-RPC` transport, manifest
+//! parsing, and ADR-021 core-enforced minimums.
+
+pub mod manifest;
+pub mod protocol;
+pub mod transport;
+
+#[cfg(test)]
+pub(crate) mod mock;
+
+pub use manifest::{
+    Capabilities, Manifest, ManifestError, Ontology, PluginHeader, parse_manifest,
+};
+pub use protocol::{
+    AnalyzeFileParams, AnalyzeFileResult, InitializeParams, InitializeResult, JsonRpcError,
+    JsonRpcErrorCode, JsonRpcRequest, JsonRpcResponse, Method, PluginEntity, PluginSource,
+    ShutdownParams,
+};
+pub use transport::{Frame, TransportError, read_frame, write_frame};
+```
+
+- [ ] **Step 2: Write the mock module**
+
+Create `/home/john/clarion/crates/clarion-core/src/plugin/mock.rs`:
+
+```rust
+//! In-process mock plugin for unit tests.
+//!
+//! Provides a [`MockPlugin`] that owns one side of a `tokio::io::duplex`
+//! pair and runs a small tokio task pretending to be a plugin. The host
+//! drives the other side. This lets us unit-test the transport +
+//! handshake logic without a real subprocess.
+//!
+//! Task 6's integration tests use a real subprocess fixture
+//! (`clarion-mock-plugin` crate). This module is for unit-level coverage.
+//!
+//! # UQ-WP2-07 note
+//!
+//! Mock plugins do not emit stderr. Real plugins write free-form stderr
+//! which the host forwards to `tracing::info!` — that forwarding path is
+//! exercised by the subprocess integration tests.
+//!
+//! # UQ-WP2-08 note
+//!
+//! Stdout is `JSON-RPC` only. A plugin that prints to stdout corrupts
+//! framing → transport parse error → plugin killed. This is plugin-author
+//! discipline, not core enforcement; see the plugin-author guide.
+
+use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream, duplex};
+use tokio::task::JoinHandle;
+
+use super::protocol::{
+    AnalyzeFileParams, AnalyzeFileResult, InitializeParams, InitializeResult, JsonRpcRequest,
+    JsonRpcResponse, Method, PluginEntity, PluginSource,
+};
+use super::transport::{read_frame, write_frame};
+
+/// Which behaviour the mock adopts.
+#[derive(Debug, Clone, Copy)]
+pub enum MockVariant {
+    /// Completes handshake and returns one valid entity per `analyze_file`.
+    Compliant,
+    /// Completes handshake then exits the task without responding further
+    /// — simulates a plugin that dies mid-run.
+    CrashingAfterHandshake,
+    /// On first `analyze_file`, writes a body larger than 128 bytes —
+    /// paired with a ceiling of 128 in the test so the host trips on it.
+    Oversize,
+}
+
+/// Host-side handles returned when a mock is started.
+pub struct MockPlugin {
+    /// Host writes requests here.
+    pub host_to_plugin: DuplexStream,
+    /// Host reads responses from here.
+    pub plugin_to_host: DuplexStream,
+    /// Background task running the mock's protocol logic.
+    pub task: JoinHandle<()>,
+}
+
+const CEILING_FOR_TESTS: usize = 8 * 1024 * 1024;
+
+impl MockPlugin {
+    /// Spawn a mock of the chosen variant. Returns the two `DuplexStream`
+    /// ends for the host side plus a task handle.
+    pub fn spawn(variant: MockVariant) -> Self {
+        // Two duplex pairs: one carries host→plugin, the other plugin→host.
+        let (host_write, mut plugin_read) = duplex(4096);
+        let (mut plugin_write, host_read) = duplex(4096);
+        let task = tokio::spawn(async move {
+            run_mock(variant, &mut plugin_read, &mut plugin_write).await;
+        });
+        Self {
+            host_to_plugin: host_write,
+            plugin_to_host: host_read,
+            task,
+        }
+    }
+}
+
+async fn run_mock(variant: MockVariant, rx: &mut DuplexStream, tx: &mut DuplexStream) {
+    // ---- handshake: expect initialize request ----
+    let Ok(frame) = read_frame(rx, CEILING_FOR_TESTS).await else {
+        return;
+    };
+    let req: JsonRpcRequest = match serde_json::from_slice(&frame.body) {
+        Ok(r) => r,
+        Err(_) => return,
+    };
+    if req.method != Method::Initialize.as_str() {
+        return;
+    }
+    let Some(id) = req.id.clone() else { return };
+    let params: InitializeParams = match req.params {
+        Some(ref v) => match serde_json::from_value(v.clone()) {
+            Ok(p) => p,
+            Err(_) => return,
+        },
+        None => return,
+    };
+    let init_result = InitializeResult {
+        protocol_version: params.protocol_version,
+        plugin_name: params.plugin_name,
+        plugin_version: params.plugin_version,
+    };
+    let resp = JsonRpcResponse {
+        jsonrpc: "2.0".to_owned(),
+        result: Some(serde_json::to_value(init_result).unwrap()),
+        error: None,
+        id,
+    };
+    let body = serde_json::to_vec(&resp).unwrap();
+    if write_frame(tx, &body).await.is_err() {
+        return;
+    }
+
+    // ---- expect `initialized` notification ----
+    let Ok(frame) = read_frame(rx, CEILING_FOR_TESTS).await else {
+        return;
+    };
+    let note: JsonRpcRequest = match serde_json::from_slice(&frame.body) {
+        Ok(r) => r,
+        Err(_) => return,
+    };
+    if note.method != Method::Initialized.as_str() {
+        return;
+    }
+
+    if matches!(variant, MockVariant::CrashingAfterHandshake) {
+        // Drop both ends to simulate a plugin exiting. The host's next
+        // read_frame sees UnexpectedEofInHeader.
+        return;
+    }
+
+    // ---- response loop ----
+    loop {
+        let frame = match read_frame(rx, CEILING_FOR_TESTS).await {
+            Ok(f) => f,
+            Err(_) => return,
+        };
+        let req: JsonRpcRequest = match serde_json::from_slice(&frame.body) {
+            Ok(r) => r,
+            Err(_) => return,
+        };
+        match req.method.as_str() {
+            "analyze_file" => {
+                let Some(id) = req.id.clone() else { return };
+                let params: AnalyzeFileParams =
+                    serde_json::from_value(req.params.unwrap_or_default()).unwrap_or(
+                        AnalyzeFileParams {
+                            path: String::new(),
+                        },
+                    );
+                if matches!(variant, MockVariant::Oversize) {
+                    // Write a frame whose Content-Length exceeds the
+                    // test ceiling (paired 128 in read_frame).
+                    let big = vec![b'x'; 1024];
+                    let _ = write_frame(tx, &big).await;
+                    return;
+                }
+                let result = AnalyzeFileResult {
+                    entities: vec![PluginEntity {
+                        id: format!("mock:function:{}", params.path.replace('/', ".")),
+                        plugin_id: "mock".to_owned(),
+                        kind: "function".to_owned(),
+                        qualified_name: params.path.replace('/', "."),
+                        short_name: params
+                            .path
+                            .rsplit_once('/')
+                            .map(|(_, s)| s.to_owned())
+                            .unwrap_or_else(|| params.path.clone()),
+                        source: PluginSource {
+                            file_path: params.path.clone(),
+                            byte_start: Some(0),
+                            byte_end: Some(10),
+                            line_start: Some(1),
+                            line_end: Some(1),
+                        },
+                        properties: serde_json::json!({}),
+                        content_hash: None,
+                        parent_id: None,
+                    }],
+                };
+                let resp = JsonRpcResponse {
+                    jsonrpc: "2.0".to_owned(),
+                    result: Some(serde_json::to_value(result).unwrap()),
+                    error: None,
+                    id,
+                };
+                let body = serde_json::to_vec(&resp).unwrap();
+                if write_frame(tx, &body).await.is_err() {
+                    return;
+                }
+            }
+            "shutdown" => {
+                let Some(id) = req.id.clone() else { return };
+                let resp = JsonRpcResponse {
+                    jsonrpc: "2.0".to_owned(),
+                    result: Some(serde_json::json!({})),
+                    error: None,
+                    id,
+                };
+                let body = serde_json::to_vec(&resp).unwrap();
+                let _ = write_frame(tx, &body).await;
+            }
+            "exit" => return,
+            _ => return,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use super::super::protocol::{InitializeParams, Method};
+
+    #[tokio::test]
+    async fn compliant_mock_completes_handshake() {
+        let mut mock = MockPlugin::spawn(MockVariant::Compliant);
+
+        let req = JsonRpcRequest::new(
+            1,
+            Method::Initialize,
+            &InitializeParams {
+                protocol_version: "1.0".to_owned(),
+                plugin_name: "clarion-plugin-mock".to_owned(),
+                plugin_version: "0.1.0".to_owned(),
+                project_root: "/tmp".to_owned(),
+            },
+        )
+        .unwrap();
+        let body = serde_json::to_vec(&req).unwrap();
+        write_frame(&mut mock.host_to_plugin, &body).await.unwrap();
+
+        let frame = read_frame(&mut mock.plugin_to_host, CEILING_FOR_TESTS)
+            .await
+            .unwrap();
+        let resp: JsonRpcResponse = serde_json::from_slice(&frame.body).unwrap();
+        assert!(resp.error.is_none(), "handshake returned error: {resp:?}");
+        assert_eq!(resp.id, serde_json::Value::from(1));
+
+        let note = JsonRpcRequest::notification(Method::Initialized, &serde_json::json!({}))
+            .unwrap();
+        let body = serde_json::to_vec(¬e).unwrap();
+        write_frame(&mut mock.host_to_plugin, &body).await.unwrap();
+
+        // Send an analyze_file request and read the one-entity response.
+        let req = JsonRpcRequest::new(
+            2,
+            Method::AnalyzeFile,
+            &AnalyzeFileParams {
+                path: "demo/hello.py".to_owned(),
+            },
+        )
+        .unwrap();
+        let body = serde_json::to_vec(&req).unwrap();
+        write_frame(&mut mock.host_to_plugin, &body).await.unwrap();
+
+        let frame = read_frame(&mut mock.plugin_to_host, CEILING_FOR_TESTS)
+            .await
+            .unwrap();
+        let resp: JsonRpcResponse = serde_json::from_slice(&frame.body).unwrap();
+        let result: AnalyzeFileResult =
+            serde_json::from_value(resp.result.expect("result present")).unwrap();
+        assert_eq!(result.entities.len(), 1);
+        assert_eq!(result.entities[0].kind, "function");
+        assert_eq!(result.entities[0].source.file_path, "demo/hello.py");
+
+        // Tell the mock to exit so the task joins cleanly.
+        let note = JsonRpcRequest::notification(Method::Exit, &serde_json::json!({})).unwrap();
+        let body = serde_json::to_vec(¬e).unwrap();
+        write_frame(&mut mock.host_to_plugin, &body).await.unwrap();
+        mock.task.await.unwrap();
+    }
+}
+```
+
+Note the minor type-level annoyance: we `use super::super::protocol::{InitializeParams, Method};` inside the test module because the reach-through re-export from `plugin/mod.rs` doesn't include them when `mock` is a `pub(crate)` submodule. The fully qualified path is accurate.
+
+- [ ] **Step 3: Run the mock test**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-core mock --no-tests=pass
+```
+
+Expected: 1 test (`compliant_mock_completes_handshake`) passes.
+
+- [ ] **Step 4: Full ADR-023 gate sweep + flake-check**
+
+```bash
+cd /home/john/clarion && cargo fmt --all -- --check
+cd /home/john/clarion && cargo clippy --workspace --all-targets --all-features -- -D warnings
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo doc --workspace --no-deps --all-features
+cd /home/john/clarion && cargo deny check
+```
+
+Three nextest runs in a row verify the async mock doesn't flake (timing-dependent tests are a WP2 hotspot). If any run hangs beyond 30s, kill it and investigate — the duplex-drop ordering is the usual suspect.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-core/ && git commit -m "$(cat <<'EOF'
+feat(wp2): in-process mock plugin test harness
+
+plugin/mock.rs provides MockPlugin::spawn(variant) over tokio::io::duplex
+pairs. Variants: Compliant (handshake + one-entity analyze_file response),
+CrashingAfterHandshake (drops pipes post-handshake), Oversize (writes a
+1024-byte frame so tests with ceiling=128 trip FrameTooLarge).
+
+Unit-level coverage: Task 6's real-subprocess integration tests use the
+separate clarion-mock-plugin crate spawned via assert_cmd::cargo_bin.
+
+1 test asserts the compliant variant's full handshake + analyze_file
+round trip. Module is #[cfg(test)] pub(crate) — no runtime impact on
+release builds.
+EOF
+)"
+```
+
+---
+
+## Task 4: L6 core-enforced minimums — jail, limits, prlimit
+
+**Files:**
+- Modify: `/home/john/clarion/Cargo.toml` (add `nix` workspace dep; relax `unsafe_code` from `"forbid"` to `"deny"`)
+- Modify: `/home/john/clarion/crates/clarion-core/Cargo.toml` (add `nix` as a `[target.'cfg(target_os = "linux")'.dependencies]` entry; add `tracing`)
+- Create: `/home/john/clarion/crates/clarion-core/src/plugin/jail.rs`
+- Create: `/home/john/clarion/crates/clarion-core/src/plugin/limits.rs`
+- Modify: `/home/john/clarion/crates/clarion-core/src/plugin/mod.rs` (declare `jail` + `limits`; add re-exports)
+
+- [ ] **Step 1: Relax workspace `unsafe_code` lint**
+
+In `/home/john/clarion/Cargo.toml`, change the `[workspace.lints.rust]` block from:
+
+```toml
+[workspace.lints.rust]
+unsafe_code = "forbid"
+```
+
+to:
+
+```toml
+[workspace.lints.rust]
+# ADR-021 §2d enforcement requires CommandExt::pre_exec (unsafe because the
+# closure runs in the fork'd child before exec). Relaxed from "forbid" to
+# "deny" so the single audited call site in
+# crates/clarion-core/src/plugin/limits.rs can use `#[allow(unsafe_code)]`
+# with a safety-justifying comment. No other unsafe is permitted.
+unsafe_code = "deny"
+```
+
+- [ ] **Step 2: Add `nix` workspace dep**
+
+Append to `[workspace.dependencies]` in `/home/john/clarion/Cargo.toml`:
+
+```toml
+nix = { version = "0.28", default-features = false, features = ["resource"] }
+tracing-test = "0.2"
+```
+
+`tracing-test` is a dev-only helper that captures `tracing` events for assertion; we pin it at workspace level for reuse across crates.
+
+- [ ] **Step 3: Extend `clarion-core/Cargo.toml`**
+
+Modify `/home/john/clarion/crates/clarion-core/Cargo.toml` to add `tracing` + the target-scoped `nix`:
+
+```toml
+[dependencies]
+serde = { workspace = true, features = ["derive"] }
+serde_json.workspace = true
+thiserror.workspace = true
+tokio = { workspace = true, features = ["io-util", "process"] }
+toml.workspace = true
+tracing.workspace = true
+
+[target.'cfg(target_os = "linux")'.dependencies]
+nix.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
+tokio = { workspace = true, features = ["macros", "io-util", "rt", "rt-multi-thread"] }
+tracing-test.workspace = true
+```
+
+(Keep any pre-existing entries; the snippet is the full `[dependencies]` / `[dev-dependencies]` / target-scoped section after this task.)
+
+- [ ] **Step 4: Write `jail.rs` tests first**
+
+Create `/home/john/clarion/crates/clarion-core/src/plugin/jail.rs`:
+
+```rust
+//! Path-jail helper — ADR-021 §2a enforcement primitive.
+//!
+//! `jail(root, candidate)` canonicalises both via `std::fs::canonicalize`
+//! (follows symlinks per UQ-WP2-03) and returns the canonical candidate if
+//! it lies under `root`. A canonical candidate outside `root` returns
+//! [`JailError::EscapedRoot`]; the caller decides whether to drop the
+//! offending record (ADR-021 §2a first-offense policy) or kill the plugin
+//! (path-escape sub-breaker trip — handled in [`super::limits`]).
+
+use std::path::{Path, PathBuf};
+
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum JailError {
+    #[error("cannot canonicalise {role} path {path:?}: {source}")]
+    Canonicalise {
+        role: &'static str,
+        path: PathBuf,
+        #[source]
+        source: std::io::Error,
+    },
+
+    #[error(
+        "{candidate:?} canonicalises outside project root {root:?} \
+         (rule-id CLA-INFRA-PLUGIN-PATH-ESCAPE)"
+    )]
+    EscapedRoot { candidate: PathBuf, root: PathBuf },
+}
+
+/// Return the canonical form of `candidate` if it lies under `root`.
+///
+/// # Errors
+///
+/// - [`JailError::Canonicalise`] if either path cannot be canonicalised
+///   (non-existent, permission denied, etc.).
+/// - [`JailError::EscapedRoot`] if the canonical candidate does not have
+///   the canonical root as a prefix.
+pub fn jail(root: &Path, candidate: &Path) -> Result {
+    let canon_root = root.canonicalize().map_err(|e| JailError::Canonicalise {
+        role: "root",
+        path: root.to_path_buf(),
+        source: e,
+    })?;
+    let canon_candidate = candidate
+        .canonicalize()
+        .map_err(|e| JailError::Canonicalise {
+            role: "candidate",
+            path: candidate.to_path_buf(),
+            source: e,
+        })?;
+    if canon_candidate.starts_with(&canon_root) {
+        Ok(canon_candidate)
+    } else {
+        Err(JailError::EscapedRoot {
+            candidate: canon_candidate,
+            root: canon_root,
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::fs;
+
+    #[test]
+    fn admits_path_inside_root() {
+        let tmp = tempfile::tempdir().unwrap();
+        let inside = tmp.path().join("inside.txt");
+        fs::write(&inside, b"").unwrap();
+        let admitted = jail(tmp.path(), &inside).unwrap();
+        assert!(admitted.starts_with(tmp.path().canonicalize().unwrap()));
+    }
+
+    #[test]
+    fn rejects_parent_escape_via_dotdot() {
+        let tmp = tempfile::tempdir().unwrap();
+        let sub = tmp.path().join("sub");
+        fs::create_dir(&sub).unwrap();
+        let outside = tmp.path().join("outside.txt");
+        fs::write(&outside, b"").unwrap();
+        let escaping = sub.join("..").join("outside.txt");
+        let err = jail(&sub, &escaping).unwrap_err();
+        assert!(matches!(err, JailError::EscapedRoot { .. }), "got {err:?}");
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn rejects_symlink_pointing_outside_root() {
+        use std::os::unix::fs::symlink;
+        let tmp = tempfile::tempdir().unwrap();
+        let root = tmp.path().join("root");
+        fs::create_dir(&root).unwrap();
+        let outside = tmp.path().join("outside.txt");
+        fs::write(&outside, b"").unwrap();
+        let link = root.join("link");
+        symlink(&outside, &link).unwrap();
+        let err = jail(&root, &link).unwrap_err();
+        assert!(matches!(err, JailError::EscapedRoot { .. }), "got {err:?}");
+    }
+
+    #[test]
+    fn rejects_nonexistent_candidate() {
+        let tmp = tempfile::tempdir().unwrap();
+        let ghost = tmp.path().join("ghost.txt");
+        let err = jail(tmp.path(), &ghost).unwrap_err();
+        assert!(matches!(err, JailError::Canonicalise { role: "candidate", .. }), "got {err:?}");
+    }
+
+    #[test]
+    fn admits_nested_path() {
+        let tmp = tempfile::tempdir().unwrap();
+        let nested_dir = tmp.path().join("a").join("b").join("c");
+        fs::create_dir_all(&nested_dir).unwrap();
+        let nested_file = nested_dir.join("deep.txt");
+        fs::write(&nested_file, b"").unwrap();
+        let admitted = jail(tmp.path(), &nested_file).unwrap();
+        assert!(admitted.ends_with("deep.txt"));
+    }
+}
+```
+
+- [ ] **Step 5: Write `limits.rs` tests first**
+
+Create `/home/john/clarion/crates/clarion-core/src/plugin/limits.rs`:
+
+```rust
+//! Core-enforced ceilings + rolling breakers — ADR-021 §2b/§2c/§2d +
+//! path-escape sub-breaker (ADR-021 §2a).
+//!
+//! This module exposes four primitives:
+//!
+//! - [`ContentLengthCeiling`] — per-frame inbound byte ceiling. Consumed
+//!   by [`super::transport::read_frame`] which already enforces the
+//!   numerical comparison; this type exists so the default (8 MiB) and
+//!   floor (1 MiB) are named once (ADR-021 §2b).
+//! - [`EntityCountCap`] — per-run cumulative cap on `entity + edge +
+//!   finding` notifications. Default 500,000, floor 10,000 (ADR-021 §2c).
+//! - [`PathEscapeBreaker`] — rolling-window breaker on jail violations.
+//!   Default >10 in 60s (ADR-021 §2a sub-breaker). Per Task 6, the host
+//!   consults this after each [`super::jail::jail`] violation.
+//! - [`apply_prlimit_as`] — installs `RLIMIT_AS` via `setrlimit` inside
+//!   `CommandExt::pre_exec`. Default 2 GiB, floor 512 MiB (ADR-021 §2d).
+//!   Linux-only; on other targets, a one-shot warning is logged.
+//!
+//! # Safety
+//!
+//! [`apply_prlimit_as`] contains the single audited `#[allow(unsafe_code)]`
+//! call site in the workspace. The closure passed to `pre_exec` runs in the
+//! fork'd child between `fork()` and `execvp()`; the closure body is
+//! async-signal-safe (only `setrlimit`, an AS-safe syscall). See the
+//! comment above the `unsafe` block for the full safety argument.
+
+use std::collections::VecDeque;
+use std::time::{Duration, Instant};
+
+use thiserror::Error;
+
+// ===== Content-Length ceiling =====
+
+/// ADR-021 §2b default: 8 MiB per inbound frame.
+pub const DEFAULT_CONTENT_LENGTH_CEILING: usize = 8 * 1024 * 1024;
+/// ADR-021 §2b floor: 1 MiB.
+pub const CONTENT_LENGTH_CEILING_FLOOR: usize = 1024 * 1024;
+
+/// Content-Length ceiling with a floor-clamped constructor.
+#[derive(Debug, Clone, Copy)]
+pub struct ContentLengthCeiling {
+    bytes: usize,
+}
+
+impl ContentLengthCeiling {
+    pub fn new(bytes: usize) -> Self {
+        Self {
+            bytes: bytes.max(CONTENT_LENGTH_CEILING_FLOOR),
+        }
+    }
+
+    pub fn bytes(self) -> usize {
+        self.bytes
+    }
+}
+
+impl Default for ContentLengthCeiling {
+    fn default() -> Self {
+        Self::new(DEFAULT_CONTENT_LENGTH_CEILING)
+    }
+}
+
+// ===== Entity-count cap =====
+
+/// ADR-021 §2c default: 500,000 combined records per run.
+pub const DEFAULT_ENTITY_COUNT_CAP: u64 = 500_000;
+/// ADR-021 §2c floor: 10,000 combined records per run.
+pub const ENTITY_COUNT_CAP_FLOOR: u64 = 10_000;
+
+#[derive(Debug, Error)]
+#[error(
+    "per-run entity-count cap exceeded: {observed} > {cap} \
+     (rule-id CLA-INFRA-PLUGIN-ENTITY-CAP)"
+)]
+pub struct CapExceeded {
+    pub observed: u64,
+    pub cap: u64,
+}
+
+/// Rolling counter across one `analyze` run.
+#[derive(Debug, Clone)]
+pub struct EntityCountCap {
+    cap: u64,
+    observed: u64,
+}
+
+impl EntityCountCap {
+    pub fn new(cap: u64) -> Self {
+        Self {
+            cap: cap.max(ENTITY_COUNT_CAP_FLOOR),
+            observed: 0,
+        }
+    }
+
+    /// Admit `delta` records. Returns `Ok(())` when the cumulative count
+    /// stays at or below the cap; otherwise [`CapExceeded`] and the
+    /// internal counter is left at the overflow value (so a follow-up
+    /// call also fails — makes the error sticky until the run resets).
+    ///
+    /// # Errors
+    ///
+    /// Returns [`CapExceeded`] when admitting `delta` would push the
+    /// cumulative count above the cap.
+    pub fn try_admit(&mut self, delta: u64) -> Result<(), CapExceeded> {
+        let proposed = self.observed.saturating_add(delta);
+        if proposed > self.cap {
+            self.observed = proposed;
+            return Err(CapExceeded {
+                observed: self.observed,
+                cap: self.cap,
+            });
+        }
+        self.observed = proposed;
+        Ok(())
+    }
+
+    pub fn observed(&self) -> u64 {
+        self.observed
+    }
+}
+
+impl Default for EntityCountCap {
+    fn default() -> Self {
+        Self::new(DEFAULT_ENTITY_COUNT_CAP)
+    }
+}
+
+// ===== Path-escape sub-breaker =====
+
+/// ADR-021 §2a sub-breaker default: >10 escapes in 60s.
+pub const DEFAULT_PATH_ESCAPE_LIMIT: usize = 10;
+pub const DEFAULT_PATH_ESCAPE_WINDOW: Duration = Duration::from_secs(60);
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BreakerState {
+    Closed,
+    Tripped,
+}
+
+#[derive(Debug, Clone)]
+pub struct PathEscapeBreaker {
+    limit: usize,
+    window: Duration,
+    events: VecDeque,
+}
+
+impl PathEscapeBreaker {
+    pub fn new(limit: usize, window: Duration) -> Self {
+        Self {
+            limit,
+            window,
+            events: VecDeque::new(),
+        }
+    }
+
+    /// Record one path-escape event at the given timestamp. Returns the
+    /// breaker state AFTER recording. `Tripped` is returned once the count
+    /// within the window crosses `limit` (i.e. the 11th event at default
+    /// limit=10 trips).
+    pub fn record_escape_at(&mut self, now: Instant) -> BreakerState {
+        let cutoff = now.checked_sub(self.window).unwrap_or(now);
+        while let Some(front) = self.events.front() {
+            if *front < cutoff {
+                self.events.pop_front();
+            } else {
+                break;
+            }
+        }
+        self.events.push_back(now);
+        if self.events.len() > self.limit {
+            BreakerState::Tripped
+        } else {
+            BreakerState::Closed
+        }
+    }
+
+    /// Convenience wrapper using `Instant::now()`.
+    pub fn record_escape(&mut self) -> BreakerState {
+        self.record_escape_at(Instant::now())
+    }
+
+    pub fn events_in_window(&self) -> usize {
+        self.events.len()
+    }
+}
+
+impl Default for PathEscapeBreaker {
+    fn default() -> Self {
+        Self::new(DEFAULT_PATH_ESCAPE_LIMIT, DEFAULT_PATH_ESCAPE_WINDOW)
+    }
+}
+
+// ===== RLIMIT_AS via pre_exec =====
+
+/// ADR-021 §2d default: 2 GiB virtual-memory ceiling.
+pub const DEFAULT_RLIMIT_AS_BYTES: u64 = 2 * 1024 * 1024 * 1024;
+/// ADR-021 §2d floor: 512 MiB.
+pub const RLIMIT_AS_FLOOR_BYTES: u64 = 512 * 1024 * 1024;
+
+/// Install `RLIMIT_AS` on `cmd` so the spawned child inherits the cap.
+///
+/// `max_as_bytes` is clamped against [`RLIMIT_AS_FLOOR_BYTES`]. On Linux,
+/// the limit is applied inside `CommandExt::pre_exec` — the closure runs
+/// in the fork'd child between `fork()` and `execvp()`, so a failure there
+/// returns an `io::Error` from the parent's `Command::spawn()`.
+///
+/// On non-Linux targets (Sprint 1 is Linux-only), this function logs a
+/// one-shot `tracing::warn!` and returns without modifying the command.
+/// See UQ-WP2-06 for the deferral rationale.
+#[cfg(target_os = "linux")]
+pub fn apply_prlimit_as(cmd: &mut std::process::Command, max_as_bytes: u64) {
+    use std::os::unix::process::CommandExt;
+
+    let clamped = max_as_bytes.max(RLIMIT_AS_FLOOR_BYTES);
+
+    // SAFETY: ADR-021 §2d enforcement point. The closure passed to
+    // pre_exec runs in the fork'd child between fork() and execvp().
+    // At that point, only async-signal-safe calls are permitted. The
+    // body calls `nix::sys::resource::setrlimit(Resource::RLIMIT_AS, _, _)`
+    // which wraps the `setrlimit(2)` libc function — setrlimit is listed
+    // in POSIX.1-2017 §2.4.3 as async-signal-safe. No allocation, no
+    // locking, no Rust std calls that could be unsafe post-fork. A
+    // failure from setrlimit propagates as an `io::Error` to the parent,
+    // which then reports the spawn failure to the caller.
+    #[allow(unsafe_code)]
+    unsafe {
+        cmd.pre_exec(move || {
+            use nix::sys::resource::{Resource, setrlimit};
+            setrlimit(Resource::RLIMIT_AS, clamped, clamped)
+                .map_err(|errno| std::io::Error::from_raw_os_error(errno as i32))?;
+            Ok(())
+        });
+    }
+}
+
+#[cfg(not(target_os = "linux"))]
+pub fn apply_prlimit_as(_cmd: &mut std::process::Command, _max_as_bytes: u64) {
+    use std::sync::atomic::{AtomicBool, Ordering};
+    static WARNED: AtomicBool = AtomicBool::new(false);
+    if !WARNED.swap(true, Ordering::Relaxed) {
+        tracing::warn!(
+            "RLIMIT_AS enforcement not implemented on this target \
+             (ADR-021 §2d — resolution per UQ-WP2-06 deferred to macOS \
+             support sprint); proceeding without a memory ceiling"
+        );
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn content_length_ceiling_defaults_to_8_mib() {
+        let c = ContentLengthCeiling::default();
+        assert_eq!(c.bytes(), 8 * 1024 * 1024);
+    }
+
+    #[test]
+    fn content_length_ceiling_clamps_below_floor() {
+        let c = ContentLengthCeiling::new(1024);
+        assert_eq!(c.bytes(), 1024 * 1024);
+    }
+
+    #[test]
+    fn content_length_ceiling_honours_above_floor() {
+        let c = ContentLengthCeiling::new(16 * 1024 * 1024);
+        assert_eq!(c.bytes(), 16 * 1024 * 1024);
+    }
+
+    #[test]
+    fn entity_count_cap_admits_under_limit() {
+        let mut cap = EntityCountCap::new(100);
+        assert!(cap.try_admit(50).is_ok());
+        assert!(cap.try_admit(50).is_ok());
+        assert_eq!(cap.observed(), 100);
+    }
+
+    #[test]
+    fn entity_count_cap_rejects_over_limit() {
+        let mut cap = EntityCountCap::new(100);
+        assert!(cap.try_admit(50).is_ok());
+        let err = cap.try_admit(51).unwrap_err();
+        assert_eq!(err.cap, 100);
+        assert_eq!(err.observed, 101);
+    }
+
+    #[test]
+    fn entity_count_cap_clamps_below_floor() {
+        let cap = EntityCountCap::new(1);
+        // Floor is 10,000; passed 1 → clamped up.
+        let mut cap = cap;
+        assert!(cap.try_admit(9_999).is_ok());
+        assert!(cap.try_admit(1).is_ok());
+        let err = cap.try_admit(1).unwrap_err();
+        assert_eq!(err.cap, 10_000);
+    }
+
+    #[test]
+    fn path_escape_breaker_eleventh_event_trips_at_default() {
+        let mut b = PathEscapeBreaker::default();
+        let base = Instant::now();
+        for i in 0..10 {
+            assert_eq!(
+                b.record_escape_at(base + Duration::from_millis(i * 10)),
+                BreakerState::Closed,
+                "event {i} unexpectedly tripped"
+            );
+        }
+        assert_eq!(
+            b.record_escape_at(base + Duration::from_millis(200)),
+            BreakerState::Tripped,
+            "11th event did not trip"
+        );
+    }
+
+    #[test]
+    fn path_escape_breaker_drops_events_outside_window() {
+        let mut b = PathEscapeBreaker::new(2, Duration::from_secs(1));
+        let base = Instant::now();
+        // Two events inside a 1s window — still Closed.
+        assert_eq!(b.record_escape_at(base), BreakerState::Closed);
+        assert_eq!(
+            b.record_escape_at(base + Duration::from_millis(500)),
+            BreakerState::Closed
+        );
+        // A third event > 1s later should find the first two expired, so
+        // only the second + this one are "in-window" — 2 events, limit 2,
+        // which is NOT strictly greater → still Closed.
+        assert_eq!(
+            b.record_escape_at(base + Duration::from_millis(1_600)),
+            BreakerState::Closed,
+            "sliding window failed to drop the first event"
+        );
+    }
+
+    #[cfg(target_os = "linux")]
+    #[test]
+    fn apply_prlimit_as_clamps_below_floor_then_succeeds() {
+        // Smoke test: install a floor-clamped limit on a sleep command and
+        // make sure spawn returns Ok. We don't try to *trigger* the cap
+        // (that would require a program that allocates >512 MiB, which is
+        // flaky under CI resource constraints).
+        let mut cmd = std::process::Command::new("true");
+        apply_prlimit_as(&mut cmd, 1);  // clamps to 512 MiB
+        let status = cmd.status().expect("spawn `true` with RLIMIT_AS");
+        assert!(status.success());
+    }
+}
+```
+
+- [ ] **Step 6: Extend `plugin/mod.rs` to declare the new modules**
+
+Replace `plugin/mod.rs`:
+
+```rust
+//! Plugin host — subprocess supervision, `JSON-RPC` transport, manifest
+//! parsing, and ADR-021 core-enforced minimums.
+
+pub mod jail;
+pub mod limits;
+pub mod manifest;
+pub mod protocol;
+pub mod transport;
+
+#[cfg(test)]
+pub(crate) mod mock;
+
+pub use jail::{JailError, jail};
+pub use limits::{
+    BreakerState, CONTENT_LENGTH_CEILING_FLOOR, CapExceeded, ContentLengthCeiling,
+    DEFAULT_CONTENT_LENGTH_CEILING, DEFAULT_ENTITY_COUNT_CAP, DEFAULT_PATH_ESCAPE_LIMIT,
+    DEFAULT_PATH_ESCAPE_WINDOW, DEFAULT_RLIMIT_AS_BYTES, ENTITY_COUNT_CAP_FLOOR,
+    EntityCountCap, PathEscapeBreaker, RLIMIT_AS_FLOOR_BYTES, apply_prlimit_as,
+};
+pub use manifest::{
+    Capabilities, Manifest, ManifestError, Ontology, PluginHeader, parse_manifest,
+};
+pub use protocol::{
+    AnalyzeFileParams, AnalyzeFileResult, InitializeParams, InitializeResult, JsonRpcError,
+    JsonRpcErrorCode, JsonRpcRequest, JsonRpcResponse, Method, PluginEntity, PluginSource,
+    ShutdownParams,
+};
+pub use transport::{Frame, TransportError, read_frame, write_frame};
+```
+
+- [ ] **Step 7: Run the jail + limits tests**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-core jail --no-tests=pass
+cd /home/john/clarion && cargo nextest run -p clarion-core limits --no-tests=pass
+```
+
+Expected: 5 jail tests pass, 8 limits tests pass (one of which is Linux-gated).
+
+- [ ] **Step 8: Full ADR-023 gate sweep + breaker flake-check**
+
+```bash
+cd /home/john/clarion && cargo fmt --all -- --check
+cd /home/john/clarion && cargo clippy --workspace --all-targets --all-features -- -D warnings
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo doc --workspace --no-deps --all-features
+cd /home/john/clarion && cargo deny check
+```
+
+Three nextest runs because the breaker tests are timing-adjacent (they use synthetic `Instant`s — should be deterministic — but the triple run catches any accidental `Instant::now()` slippage).
+
+If `cargo deny check` complains about `nix`'s license or dependencies, add the specific license to `deny.toml`'s `[licenses].allow` list **with a commit-message note**; do not disable the gate.
+
+- [ ] **Step 9: Commit**
+
+```bash
+cd /home/john/clarion && git add Cargo.toml crates/clarion-core/ && git commit -m "$(cat <<'EOF'
+feat(wp2): L6 core-enforced minimums — path jail, ceilings, prlimit (ADR-021 defaults)
+
+plugin/jail.rs — `jail(root, candidate) -> Result`
+canonicalises via std::fs::canonicalize (follows symlinks per UQ-WP2-03)
+and rejects anything whose canonical form doesn't start_with the canonical
+root. Task 6's host drops offending records on first violation and ticks
+the PathEscapeBreaker; the ADR-021 §2a drop-not-kill policy is the
+caller's concern, not this helper's.
+
+plugin/limits.rs — four primitives:
+  * ContentLengthCeiling: 8 MiB default, 1 MiB floor (ADR-021 §2b).
+  * EntityCountCap: 500k default, 10k floor, sticky-error try_admit
+    (ADR-021 §2c).
+  * PathEscapeBreaker: >10 escapes in 60s trips (ADR-021 §2a sub-breaker).
+    Rolling window via VecDeque; test uses synthetic timestamps.
+  * apply_prlimit_as: sets RLIMIT_AS via CommandExt::pre_exec. 2 GiB
+    default, 512 MiB floor. Linux-only; non-Linux targets log a one-shot
+    tracing::warn! and skip enforcement (UQ-WP2-06).
+
+Workspace: unsafe_code = "forbid" → "deny". Single audited
+#[allow(unsafe_code)] call site at plugin/limits.rs with a fork-safety
+comment. nix = "0.28" (features=["resource"]) added as a target-scoped
+linux-only dependency. tracing-test = "0.2" added as a workspace
+dev-dependency for later tests.
+
+5 jail tests (admit-inside, reject-dotdot, reject-symlink, reject-absent,
+admit-nested). 8 limits tests (ceiling default/floor, cap admit/reject/
+floor, breaker 11th-event-trips, breaker window-drop, prlimit smoke).
+EOF
+)"
+```
+
+---
+
+## Task 5: L9 plugin discovery
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-core/src/plugin/discovery.rs`
+- Modify: `/home/john/clarion/crates/clarion-core/src/plugin/mod.rs` (declare + re-export)
+
+- [ ] **Step 1: Extend `plugin/mod.rs`**
+
+Add to the module declarations in `plugin/mod.rs`:
+
+```rust
+pub mod discovery;
+```
+
+Add to the re-exports:
+
+```rust
+pub use discovery::{DiscoveredPlugin, DiscoveryError, discover, discover_with_path};
+```
+
+- [ ] **Step 2: Write `discovery.rs` with tests first**
+
+Create `/home/john/clarion/crates/clarion-core/src/plugin/discovery.rs`:
+
+```rust
+//! Plugin discovery (L9).
+//!
+//! Convention: the core scans `$PATH` for executable files whose basename
+//! matches `clarion-plugin-*`. For each candidate binary, it looks for a
+//! `plugin.toml` alongside it; if absent, it falls back to
+//! `/../share/clarion/plugins//plugin.toml`.
+//!
+//! UQ-WP2-01 proposal: PATH-based with neighboring-manifest fallback.
+//! Resolved here.
+//!
+//! # Sprint 1 scope
+//!
+//! - Linux-only path separator (`:`). Windows support is NG for Sprint 1.
+//! - First-match-wins on duplicate basenames (stable PATH order).
+//! - A candidate with no resolvable `plugin.toml` is silently skipped and
+//!   logged at `tracing::debug!`. It is not an error — the user may have
+//!   a `clarion-plugin-*` binary on PATH that isn't a Clarion plugin.
+
+use std::path::{Path, PathBuf};
+
+use thiserror::Error;
+
+use super::manifest::{Manifest, ManifestError, parse_manifest};
+
+/// A plugin found on `$PATH` whose neighboring (or share-dir) manifest
+/// parsed successfully.
+#[derive(Debug, Clone)]
+pub struct DiscoveredPlugin {
+    pub executable: PathBuf,
+    pub manifest_path: PathBuf,
+    pub manifest: Manifest,
+}
+
+#[derive(Debug, Error)]
+pub enum DiscoveryError {
+    #[error("PATH environment variable is not set")]
+    NoPath,
+}
+
+/// Discover plugins using the process's `$PATH`.
+///
+/// # Errors
+///
+/// Returns [`DiscoveryError::NoPath`] if `$PATH` is unset.
+pub fn discover() -> Result, DiscoveryError> {
+    let path = std::env::var_os("PATH").ok_or(DiscoveryError::NoPath)?;
+    Ok(discover_with_path(path.to_string_lossy().as_ref()))
+}
+
+/// Discover plugins in the colon-separated `path` list. Testable variant
+/// of [`discover`] — tests assemble a synthetic `$PATH` pointing at
+/// tempdirs.
+pub fn discover_with_path(path: &str) -> Vec {
+    let mut out: Vec = Vec::new();
+    let mut seen: Vec = Vec::new();
+    for dir in path.split(':').filter(|s| !s.is_empty()) {
+        let dir = PathBuf::from(dir);
+        let Ok(entries) = std::fs::read_dir(&dir) else {
+            continue;
+        };
+        for entry in entries.flatten() {
+            let file_name = entry.file_name();
+            let Some(name_str) = file_name.to_str() else {
+                continue;
+            };
+            if !name_str.starts_with("clarion-plugin-") {
+                continue;
+            }
+            if seen.iter().any(|s| s == name_str) {
+                continue;
+            }
+            let exec_path = entry.path();
+            if !is_executable_file(&exec_path) {
+                continue;
+            }
+            match locate_manifest(&exec_path, name_str) {
+                Some((manifest_path, manifest)) => {
+                    seen.push(name_str.to_owned());
+                    out.push(DiscoveredPlugin {
+                        executable: exec_path,
+                        manifest_path,
+                        manifest,
+                    });
+                }
+                None => {
+                    tracing::debug!(
+                        executable = %exec_path.display(),
+                        "clarion-plugin-* candidate has no resolvable plugin.toml; skipping"
+                    );
+                }
+            }
+        }
+    }
+    out
+}
+
+fn is_executable_file(path: &Path) -> bool {
+    use std::os::unix::fs::PermissionsExt;
+    let Ok(md) = std::fs::metadata(path) else {
+        return false;
+    };
+    if !md.is_file() {
+        return false;
+    }
+    md.permissions().mode() & 0o111 != 0
+}
+
+fn locate_manifest(exec: &Path, binary_name: &str) -> Option<(PathBuf, Manifest)> {
+    // Primary: plugin.toml beside the binary.
+    if let Some(parent) = exec.parent() {
+        let candidate = parent.join("plugin.toml");
+        if let Some(manifest) = read_and_parse(&candidate) {
+            return Some((candidate, manifest));
+        }
+        // Fallback: /../share/clarion/plugins//plugin.toml
+        let candidate = parent
+            .join("..")
+            .join("share")
+            .join("clarion")
+            .join("plugins")
+            .join(binary_name)
+            .join("plugin.toml");
+        if let Some(manifest) = read_and_parse(&candidate) {
+            return Some((candidate, manifest));
+        }
+    }
+    None
+}
+
+fn read_and_parse(path: &Path) -> Option {
+    let bytes = std::fs::read(path).ok()?;
+    match parse_manifest(&bytes) {
+        Ok(m) => Some(m),
+        Err(e) => {
+            tracing::warn!(
+                path = %path.display(),
+                error = %e,
+                "plugin.toml exists but failed to parse — skipping"
+            );
+            None
+        }
+    }
+}
+
+// Exhaustive-match stub so clippy doesn't flag the ManifestError re-export
+// as dead code under some feature gates.
+#[doc(hidden)]
+pub fn __manifest_error_fan_out(_: ManifestError) {}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::fs;
+    use std::os::unix::fs::PermissionsExt;
+
+    const VALID: &[u8] = include_bytes!("../../tests/fixtures/manifest_valid.toml");
+
+    fn mk_exec(dir: &Path, name: &str) -> PathBuf {
+        let p = dir.join(name);
+        fs::write(&p, b"#!/bin/sh\nexit 0\n").unwrap();
+        let mut perm = fs::metadata(&p).unwrap().permissions();
+        perm.set_mode(0o755);
+        fs::set_permissions(&p, perm).unwrap();
+        p
+    }
+
+    #[test]
+    fn finds_plugin_with_neighboring_manifest() {
+        let tmp = tempfile::tempdir().unwrap();
+        let bin = mk_exec(tmp.path(), "clarion-plugin-python");
+        fs::write(tmp.path().join("plugin.toml"), VALID).unwrap();
+        let path = tmp.path().to_string_lossy().into_owned();
+
+        let plugins = discover_with_path(&path);
+        assert_eq!(plugins.len(), 1);
+        assert_eq!(plugins[0].executable, bin);
+        assert_eq!(plugins[0].manifest.plugin.name, "clarion-plugin-python");
+    }
+
+    #[test]
+    fn finds_plugin_via_share_fallback() {
+        let tmp = tempfile::tempdir().unwrap();
+        // Simulated install layout:
+        //   /bin/clarion-plugin-python
+        //   /share/clarion/plugins/clarion-plugin-python/plugin.toml
+        let bin_dir = tmp.path().join("bin");
+        fs::create_dir(&bin_dir).unwrap();
+        let share_dir = tmp
+            .path()
+            .join("share")
+            .join("clarion")
+            .join("plugins")
+            .join("clarion-plugin-python");
+        fs::create_dir_all(&share_dir).unwrap();
+        fs::write(share_dir.join("plugin.toml"), VALID).unwrap();
+        let bin = mk_exec(&bin_dir, "clarion-plugin-python");
+        let path = bin_dir.to_string_lossy().into_owned();
+
+        let plugins = discover_with_path(&path);
+        assert_eq!(plugins.len(), 1);
+        assert_eq!(plugins[0].executable, bin);
+    }
+
+    #[test]
+    fn skips_non_clarion_prefixed_binaries() {
+        let tmp = tempfile::tempdir().unwrap();
+        mk_exec(tmp.path(), "some-other-binary");
+        fs::write(tmp.path().join("plugin.toml"), VALID).unwrap();
+        let path = tmp.path().to_string_lossy().into_owned();
+        assert!(discover_with_path(&path).is_empty());
+    }
+
+    #[test]
+    fn skips_clarion_candidate_without_manifest() {
+        let tmp = tempfile::tempdir().unwrap();
+        mk_exec(tmp.path(), "clarion-plugin-python");
+        let path = tmp.path().to_string_lossy().into_owned();
+        assert!(discover_with_path(&path).is_empty());
+    }
+
+    #[test]
+    fn skips_non_executable_clarion_file() {
+        let tmp = tempfile::tempdir().unwrap();
+        let p = tmp.path().join("clarion-plugin-python");
+        fs::write(&p, b"not executable").unwrap();
+        let mut perm = fs::metadata(&p).unwrap().permissions();
+        perm.set_mode(0o644);
+        fs::set_permissions(&p, perm).unwrap();
+        fs::write(tmp.path().join("plugin.toml"), VALID).unwrap();
+        let path = tmp.path().to_string_lossy().into_owned();
+        assert!(discover_with_path(&path).is_empty());
+    }
+
+    #[test]
+    fn duplicate_basename_first_wins() {
+        let tmp1 = tempfile::tempdir().unwrap();
+        let tmp2 = tempfile::tempdir().unwrap();
+        mk_exec(tmp1.path(), "clarion-plugin-python");
+        fs::write(tmp1.path().join("plugin.toml"), VALID).unwrap();
+        mk_exec(tmp2.path(), "clarion-plugin-python");
+        fs::write(tmp2.path().join("plugin.toml"), VALID).unwrap();
+        let path = format!(
+            "{}:{}",
+            tmp1.path().to_string_lossy(),
+            tmp2.path().to_string_lossy()
+        );
+        let plugins = discover_with_path(&path);
+        assert_eq!(plugins.len(), 1);
+        assert!(plugins[0].executable.starts_with(tmp1.path()));
+    }
+}
+```
+
+- [ ] **Step 3: Run discovery tests**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-core discovery --no-tests=pass
+```
+
+Expected: 6 tests pass.
+
+- [ ] **Step 4: Full ADR-023 gate sweep**
+
+```bash
+cd /home/john/clarion && cargo fmt --all -- --check
+cd /home/john/clarion && cargo clippy --workspace --all-targets --all-features -- -D warnings
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo doc --workspace --no-deps --all-features
+cd /home/john/clarion && cargo deny check
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-core/ && git commit -m "$(cat <<'EOF'
+feat(wp2): L9 plugin discovery convention (PATH + neighboring manifest)
+
+plugin/discovery.rs scans $PATH for executable files prefixed
+`clarion-plugin-`. For each, it looks for plugin.toml beside the binary
+first, then falls back to /../share/clarion/plugins//
+plugin.toml. Non-matching names are skipped; candidates without a
+resolvable manifest are skipped with a debug trace; duplicate basenames
+across PATH entries keep the first-found wins.
+
+6 tests: neighboring manifest, share-fallback manifest, non-clarion prefix
+skipped, missing-manifest skipped, non-executable file skipped, PATH-order
+deduplication.
+EOF
+)"
+```
+
+---
+
+## Task 6: Plugin-host supervisor + `clarion-mock-plugin` fixture
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-mock-plugin/` (new workspace crate)
+- Create: `/home/john/clarion/crates/clarion-mock-plugin/Cargo.toml`
+- Create: `/home/john/clarion/crates/clarion-mock-plugin/src/main.rs`
+- Create: `/home/john/clarion/crates/clarion-mock-plugin/fixtures/plugin.toml`
+- Modify: `/home/john/clarion/Cargo.toml` (add `clarion-mock-plugin` to `members`)
+- Create: `/home/john/clarion/crates/clarion-core/src/plugin/host.rs`
+- Modify: `/home/john/clarion/crates/clarion-core/src/plugin/mod.rs` (declare + re-export `host`)
+- Create: `/home/john/clarion/crates/clarion-core/tests/host_integration.rs`
+
+- [ ] **Step 1: Create the fixture crate's manifest**
+
+Add `"crates/clarion-mock-plugin"` to the `members` array in the root `Cargo.toml`.
+
+Create `/home/john/clarion/crates/clarion-mock-plugin/Cargo.toml`:
+
+```toml
+[package]
+name = "clarion-mock-plugin"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+rust-version.workspace = true
+
+[lints]
+workspace = true
+
+[[bin]]
+name = "clarion-mock-plugin"
+path = "src/main.rs"
+
+[dependencies]
+anyhow.workspace = true
+clarion-core = { path = "../clarion-core", version = "0.1.0-dev" }
+serde_json.workspace = true
+tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "io-std"] }
+```
+
+Note: we need tokio's `io-std` feature for `tokio::io::{stdin, stdout}`. The feature has to be enabled additively — add it to the workspace `tokio` definition:
+
+```toml
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "io-std"] }
+```
+
+(Update the workspace `tokio` line in `/home/john/clarion/Cargo.toml` accordingly.)
+
+- [ ] **Step 2: Write the fixture plugin binary**
+
+Create `/home/john/clarion/crates/clarion-mock-plugin/src/main.rs`:
+
+```rust
+//! Fixture binary for WP2 host integration tests.
+//!
+//! Behaviour is driven by a single CLI mode argument:
+//!
+//! ```text
+//! clarion-mock-plugin compliant         # handshake + 1 valid entity per analyze_file
+//! clarion-mock-plugin undeclared-kind   # emits an entity with kind="widget"
+//! clarion-mock-plugin id-mismatch       # emits an entity whose `id` != entity_id(...)
+//! clarion-mock-plugin path-escape       # emits an entity with file_path outside project_root
+//! clarion-mock-plugin repeat-path-escape   # emits n escape entities across one analyze_file
+//! clarion-mock-plugin crash             # crashes immediately after handshake
+//! ```
+//!
+//! Reads `JSON-RPC` frames from stdin, writes frames to stdout, logs
+//! free-form text to stderr (host forwards to `tracing::info!`).
+
+use anyhow::{Context, Result};
+use clarion_core::plugin::protocol::{
+    AnalyzeFileParams, AnalyzeFileResult, InitializeParams, InitializeResult, JsonRpcRequest,
+    JsonRpcResponse, Method, PluginEntity, PluginSource,
+};
+use clarion_core::plugin::transport::{read_frame, write_frame};
+use tokio::io::{stdin, stdout};
+
+const CEILING: usize = 8 * 1024 * 1024;
+
+#[derive(Debug, Clone, Copy)]
+enum Mode {
+    Compliant,
+    UndeclaredKind,
+    IdMismatch,
+    PathEscape,
+    RepeatPathEscape(u32),
+    Crash,
+}
+
+fn parse_mode(args: &[String]) -> Result {
+    let mode = args.get(1).map(String::as_str).unwrap_or("compliant");
+    match mode {
+        "compliant" => Ok(Mode::Compliant),
+        "undeclared-kind" => Ok(Mode::UndeclaredKind),
+        "id-mismatch" => Ok(Mode::IdMismatch),
+        "path-escape" => Ok(Mode::PathEscape),
+        "repeat-path-escape" => {
+            let n: u32 = args
+                .get(2)
+                .context("repeat-path-escape requires ")?
+                .parse()
+                .context("parse n")?;
+            Ok(Mode::RepeatPathEscape(n))
+        }
+        "crash" => Ok(Mode::Crash),
+        other => anyhow::bail!("unknown mode: {other}"),
+    }
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> Result<()> {
+    let args: Vec = std::env::args().collect();
+    let mode = parse_mode(&args)?;
+
+    let mut stdin = stdin();
+    let mut stdout = stdout();
+    eprintln!("clarion-mock-plugin: started in mode {mode:?}");
+
+    // initialize
+    let frame = read_frame(&mut stdin, CEILING).await?;
+    let req: JsonRpcRequest = serde_json::from_slice(&frame.body)?;
+    let params: InitializeParams = serde_json::from_value(req.params.unwrap_or_default())?;
+    let result = InitializeResult {
+        protocol_version: params.protocol_version,
+        plugin_name: params.plugin_name,
+        plugin_version: params.plugin_version,
+    };
+    let resp = JsonRpcResponse {
+        jsonrpc: "2.0".to_owned(),
+        result: Some(serde_json::to_value(result)?),
+        error: None,
+        id: req.id.unwrap_or(serde_json::Value::Null),
+    };
+    write_frame(&mut stdout, &serde_json::to_vec(&resp)?).await?;
+
+    // initialized notification
+    let _ = read_frame(&mut stdin, CEILING).await?;
+
+    if matches!(mode, Mode::Crash) {
+        eprintln!("clarion-mock-plugin: crash mode — exit 1");
+        std::process::exit(1);
+    }
+
+    // request loop
+    loop {
+        let frame = match read_frame(&mut stdin, CEILING).await {
+            Ok(f) => f,
+            Err(_) => return Ok(()),
+        };
+        let req: JsonRpcRequest = serde_json::from_slice(&frame.body)?;
+        match req.method.as_str() {
+            m if m == Method::AnalyzeFile.as_str() => {
+                let params: AnalyzeFileParams = serde_json::from_value(
+                    req.params.unwrap_or_default(),
+                )?;
+                let result = build_response(mode, ¶ms.path);
+                let resp = JsonRpcResponse {
+                    jsonrpc: "2.0".to_owned(),
+                    result: Some(serde_json::to_value(result)?),
+                    error: None,
+                    id: req.id.unwrap_or(serde_json::Value::Null),
+                };
+                write_frame(&mut stdout, &serde_json::to_vec(&resp)?).await?;
+            }
+            m if m == Method::Shutdown.as_str() => {
+                let resp = JsonRpcResponse {
+                    jsonrpc: "2.0".to_owned(),
+                    result: Some(serde_json::json!({})),
+                    error: None,
+                    id: req.id.unwrap_or(serde_json::Value::Null),
+                };
+                write_frame(&mut stdout, &serde_json::to_vec(&resp)?).await?;
+            }
+            m if m == Method::Exit.as_str() => return Ok(()),
+            _ => {} // ignore unknown
+        }
+    }
+}
+
+fn build_response(mode: Mode, path: &str) -> AnalyzeFileResult {
+    fn valid_entity(plugin_id: &str, qualified: &str, file: &str) -> PluginEntity {
+        let short = qualified.rsplit('.').next().unwrap_or(qualified).to_owned();
+        PluginEntity {
+            id: format!("{plugin_id}:function:{qualified}"),
+            plugin_id: plugin_id.to_owned(),
+            kind: "function".to_owned(),
+            qualified_name: qualified.to_owned(),
+            short_name: short,
+            source: PluginSource {
+                file_path: file.to_owned(),
+                byte_start: Some(0),
+                byte_end: Some(10),
+                line_start: Some(1),
+                line_end: Some(1),
+            },
+            properties: serde_json::json!({}),
+            content_hash: None,
+            parent_id: None,
+        }
+    }
+
+    match mode {
+        Mode::Compliant => AnalyzeFileResult {
+            entities: vec![valid_entity("mock", "demo.hello", path)],
+        },
+        Mode::UndeclaredKind => {
+            let mut e = valid_entity("mock", "demo.widget", path);
+            e.kind = "widget".to_owned();
+            e.id = "mock:widget:demo.widget".to_owned();
+            AnalyzeFileResult { entities: vec![e] }
+        }
+        Mode::IdMismatch => {
+            let mut e = valid_entity("mock", "demo.hello", path);
+            e.id = "mock:function:not.matching".to_owned();
+            AnalyzeFileResult { entities: vec![e] }
+        }
+        Mode::PathEscape => AnalyzeFileResult {
+            entities: vec![valid_entity("mock", "demo.hello", "/tmp/outside-root.py")],
+        },
+        Mode::RepeatPathEscape(n) => AnalyzeFileResult {
+            entities: (0..n)
+                .map(|i| {
+                    valid_entity("mock", &format!("demo.esc{i}"), "/tmp/outside-root.py")
+                })
+                .collect(),
+        },
+        Mode::Crash => unreachable!(),
+    }
+}
+```
+
+- [ ] **Step 3: Create the fixture manifest**
+
+Create `/home/john/clarion/crates/clarion-mock-plugin/fixtures/plugin.toml`:
+
+```toml
+[plugin]
+name = "clarion-plugin-mock"
+version = "0.1.0"
+protocol_version = "1.0"
+executable = "clarion-mock-plugin"
+language = "mock"
+extensions = ["mock"]
+
+[capabilities]
+max_rss_mb = 512
+max_runtime_seconds = 60
+max_content_length_bytes = 8388608
+max_entities_per_run = 100000
+
+[ontology]
+entity_kinds = ["function"]
+edge_kinds = ["calls"]
+rule_id_prefix = "CLA-MOCK-"
+ontology_version = "0.1.0"
+```
+
+- [ ] **Step 4: Write the host module with integration tests first (TDD)**
+
+Create `/home/john/clarion/crates/clarion-core/tests/host_integration.rs`. Tests here are red until Step 5 lands the implementation.
+
+```rust
+//! WP2 Task 6 host-supervisor integration tests.
+//!
+//! Uses the `clarion-mock-plugin` fixture binary spawned via cargo-bin.
+//!
+//! Asserts:
+//! - handshake + analyze_file round-trip + clean shutdown (happy path)
+//! - ADR-022 ontology enforcement: kind not in manifest → entity dropped
+//! - UQ-WP2-11 identity mismatch → entity dropped
+//! - ADR-021 §2a drop-not-kill: escape path → entity dropped, plugin alive
+//! - ADR-021 §2a sub-breaker: 11 escapes → plugin killed
+
+use std::path::PathBuf;
+
+use clarion_core::plugin::host::{HostError, PluginHost};
+use clarion_core::plugin::manifest::parse_manifest;
+
+fn mock_plugin_binary() -> PathBuf {
+    PathBuf::from(env!("CARGO_BIN_EXE_clarion-mock-plugin"))
+}
+
+fn mock_manifest() -> clarion_core::plugin::Manifest {
+    let bytes = std::fs::read(
+        concat!(env!("CARGO_MANIFEST_DIR"), "/../clarion-mock-plugin/fixtures/plugin.toml"),
+    )
+    .expect("read fixture manifest");
+    parse_manifest(&bytes).expect("fixture manifest must parse")
+}
+
+async fn spawn_with_mode(mode: &str, project_root: &std::path::Path) -> PluginHost {
+    PluginHost::spawn(
+        mock_plugin_binary(),
+        vec![mode.to_owned()],
+        mock_manifest(),
+        project_root.to_path_buf(),
+    )
+    .await
+    .expect("spawn mock plugin")
+}
+
+#[tokio::test]
+async fn happy_path_handshake_and_analyze_file() {
+    let tmp = tempfile::tempdir().unwrap();
+    let target = tmp.path().join("demo.py");
+    std::fs::write(&target, b"x = 1\n").unwrap();
+
+    let mut host = spawn_with_mode("compliant", tmp.path()).await;
+    let entities = host.analyze_file(&target).await.expect("analyze_file");
+    assert_eq!(entities.len(), 1);
+    assert_eq!(entities[0].kind, "function");
+    assert_eq!(entities[0].id, "mock:function:demo.hello");
+    host.shutdown().await.expect("shutdown");
+}
+
+#[tokio::test]
+async fn undeclared_kind_entity_dropped() {
+    let tmp = tempfile::tempdir().unwrap();
+    let target = tmp.path().join("demo.py");
+    std::fs::write(&target, b"x = 1\n").unwrap();
+
+    let mut host = spawn_with_mode("undeclared-kind", tmp.path()).await;
+    let entities = host.analyze_file(&target).await.expect("analyze_file");
+    assert!(
+        entities.is_empty(),
+        "widget kind should have been dropped; got {entities:?}"
+    );
+    host.shutdown().await.expect("shutdown");
+}
+
+#[tokio::test]
+async fn id_mismatch_entity_dropped() {
+    let tmp = tempfile::tempdir().unwrap();
+    let target = tmp.path().join("demo.py");
+    std::fs::write(&target, b"x = 1\n").unwrap();
+
+    let mut host = spawn_with_mode("id-mismatch", tmp.path()).await;
+    let entities = host.analyze_file(&target).await.expect("analyze_file");
+    assert!(entities.is_empty(), "id-mismatch must drop: {entities:?}");
+    host.shutdown().await.expect("shutdown");
+}
+
+#[tokio::test]
+async fn path_escape_drops_entity_plugin_stays_alive() {
+    let tmp = tempfile::tempdir().unwrap();
+    let target = tmp.path().join("demo.py");
+    std::fs::write(&target, b"x = 1\n").unwrap();
+
+    let mut host = spawn_with_mode("path-escape", tmp.path()).await;
+    let entities = host.analyze_file(&target).await.expect("analyze_file");
+    assert!(entities.is_empty(), "escape must drop: {entities:?}");
+
+    // Plugin must still be alive — issue a second analyze_file.
+    let entities = host.analyze_file(&target).await.expect("second analyze_file");
+    assert!(entities.is_empty());
+    host.shutdown().await.expect("shutdown");
+}
+
+#[tokio::test]
+async fn eleven_escapes_trip_sub_breaker_and_kill() {
+    let tmp = tempfile::tempdir().unwrap();
+    let target = tmp.path().join("demo.py");
+    std::fs::write(&target, b"x = 1\n").unwrap();
+
+    let mut host = spawn_with_mode("repeat-path-escape 11", tmp.path()).await;
+    let err = host.analyze_file(&target).await.unwrap_err();
+    assert!(
+        matches!(err, HostError::PathEscapeBreakerTripped { .. }),
+        "expected breaker trip, got {err:?}"
+    );
+}
+```
+
+Note on fixture spawn args: `PluginHost::spawn` takes `Vec` extra args. The test passes `"repeat-path-escape 11"` as a single string; split in the implementation via `split_whitespace`. That's fine for fixture use.
+
+- [ ] **Step 5: Write `plugin/host.rs`**
+
+Create `/home/john/clarion/crates/clarion-core/src/plugin/host.rs`:
+
+```rust
+//! Plugin-host supervisor (WP2 Task 6).
+//!
+//! Spawns a plugin subprocess, performs the L4 handshake, drives
+//! `analyze_file` requests, and validates responses against ADR-022
+//! (ontology boundary + identity reconstruction) and ADR-021 §2a (path
+//! jail drop-not-kill + sub-breaker). Applies ADR-021 §2d `RLIMIT_AS` on
+//! spawn.
+//!
+//! Shutdown discipline: `shutdown` consumes `self`, sends `shutdown` then
+//! the `exit` notification, and waits for the child with a 5-second
+//! timeout before SIGKILL.
+
+use std::path::{Path, PathBuf};
+use std::process::Stdio;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Duration;
+
+use thiserror::Error;
+use tokio::io::{AsyncBufReadExt, BufReader};
+use tokio::process::{Child, ChildStdin, ChildStdout};
+use tokio::time::timeout;
+
+use crate::entity_id::{EntityIdError, entity_id};
+
+use super::jail::{JailError, jail};
+use super::limits::{
+    BreakerState, CapExceeded, ContentLengthCeiling, DEFAULT_CONTENT_LENGTH_CEILING,
+    DEFAULT_ENTITY_COUNT_CAP, DEFAULT_RLIMIT_AS_BYTES, EntityCountCap, PathEscapeBreaker,
+    apply_prlimit_as,
+};
+use super::manifest::Manifest;
+use super::protocol::{
+    AnalyzeFileParams, AnalyzeFileResult, InitializeParams, InitializeResult, JsonRpcRequest,
+    JsonRpcResponse, Method, PluginEntity, ShutdownParams,
+};
+use super::transport::{TransportError, read_frame, write_frame};
+
+#[derive(Debug, Error)]
+pub enum HostError {
+    #[error("I/O error: {0}")]
+    Io(#[from] std::io::Error),
+
+    #[error("transport: {0}")]
+    Transport(#[from] TransportError),
+
+    #[error("serde: {0}")]
+    Serde(#[from] serde_json::Error),
+
+    #[error("plugin returned JSON-RPC error: code={code} message={message}")]
+    RpcError { code: i32, message: String },
+
+    #[error("plugin shutdown did not complete within {0:?}")]
+    ShutdownTimeout(Duration),
+
+    #[error(
+        "plugin path-escape sub-breaker tripped after {count} escapes — \
+         plugin killed (rule-id CLA-INFRA-PLUGIN-DISABLED-PATH-ESCAPE)"
+    )]
+    PathEscapeBreakerTripped { count: usize },
+
+    #[error("per-run entity-count cap exceeded: {0}")]
+    EntityCap(#[from] CapExceeded),
+
+    #[error("plugin exited unexpectedly with status {status:?}")]
+    UnexpectedExit { status: Option },
+}
+
+/// The spawned plugin and its state.
+pub struct PluginHost {
+    child: Child,
+    stdin: ChildStdin,
+    stdout: ChildStdout,
+    manifest: Manifest,
+    project_root: PathBuf,
+    ceiling: ContentLengthCeiling,
+    escape_breaker: PathEscapeBreaker,
+    entity_cap: EntityCountCap,
+    next_id: AtomicU64,
+}
+
+const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// A validated entity ready for persistence. `source_file_id` /
+/// `content_hash` carry through from the plugin; translating to
+/// `clarion_storage::EntityRecord` is the caller's concern (Task 8) —
+/// this struct deliberately mirrors the plugin shape so the host
+/// doesn't need a direct dependency on `clarion-storage`.
+#[derive(Debug, Clone)]
+pub struct ValidatedEntity {
+    pub id: String,
+    pub plugin_id: String,
+    pub kind: String,
+    pub name: String,
+    pub short_name: String,
+    pub parent_id: Option,
+    pub source_file: PathBuf,
+    pub source_byte_start: Option,
+    pub source_byte_end: Option,
+    pub source_line_start: Option,
+    pub source_line_end: Option,
+    pub properties_json: String,
+    pub content_hash: Option,
+}
+
+impl PluginHost {
+    /// Spawn the plugin binary and complete the handshake.
+    ///
+    /// # Errors
+    ///
+    /// Returns a [`HostError`] variant on spawn failure, transport error,
+    /// serde error, or if the plugin returns a `JSON-RPC` error during
+    /// `initialize`.
+    pub async fn spawn(
+        executable: PathBuf,
+        extra_args: Vec,
+        manifest: Manifest,
+        project_root: PathBuf,
+    ) -> Result {
+        // Split tokens so tests can pass "repeat-path-escape 11" as one
+        // logical arg and we forward the shell-split form.
+        let args: Vec = extra_args
+            .iter()
+            .flat_map(|s| s.split_whitespace().map(str::to_owned))
+            .collect();
+
+        let effective_as = manifest
+            .capabilities
+            .max_rss_mb
+            .saturating_mul(1024 * 1024)
+            .min(DEFAULT_RLIMIT_AS_BYTES);
+
+        let mut std_cmd = std::process::Command::new(&executable);
+        std_cmd
+            .args(&args)
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped());
+        apply_prlimit_as(&mut std_cmd, effective_as);
+
+        let mut cmd = tokio::process::Command::from(std_cmd);
+        cmd.kill_on_drop(true);
+
+        let mut child = cmd.spawn()?;
+        let stdin = child
+            .stdin
+            .take()
+            .expect("piped stdin should be available post-spawn");
+        let stdout = child
+            .stdout
+            .take()
+            .expect("piped stdout should be available post-spawn");
+        if let Some(stderr) = child.stderr.take() {
+            let plugin_name = manifest.plugin.name.clone();
+            tokio::spawn(async move {
+                let mut reader = BufReader::new(stderr).lines();
+                while let Ok(Some(line)) = reader.next_line().await {
+                    tracing::info!(target: "plugin::stderr", plugin = %plugin_name, "{line}");
+                }
+            });
+        }
+
+        let mut host = Self {
+            child,
+            stdin,
+            stdout,
+            manifest,
+            project_root,
+            ceiling: ContentLengthCeiling::default(),
+            escape_breaker: PathEscapeBreaker::default(),
+            entity_cap: EntityCountCap::new(DEFAULT_ENTITY_COUNT_CAP),
+            next_id: AtomicU64::new(1),
+        };
+        host.handshake().await?;
+        Ok(host)
+    }
+
+    async fn handshake(&mut self) -> Result<(), HostError> {
+        let id = self.next_request_id();
+        let req = JsonRpcRequest::new(
+            id,
+            Method::Initialize,
+            &InitializeParams {
+                protocol_version: self.manifest.plugin.protocol_version.clone(),
+                plugin_name: self.manifest.plugin.name.clone(),
+                plugin_version: self.manifest.plugin.version.clone(),
+                project_root: self.project_root.to_string_lossy().into_owned(),
+            },
+        )?;
+        write_frame(&mut self.stdin, &serde_json::to_vec(&req)?).await?;
+        let frame = read_frame(&mut self.stdout, self.ceiling.bytes()).await?;
+        let resp: JsonRpcResponse = serde_json::from_slice(&frame.body)?;
+        if let Some(err) = resp.error {
+            return Err(HostError::RpcError {
+                code: err.code,
+                message: err.message,
+            });
+        }
+        let _result: InitializeResult = serde_json::from_value(
+            resp.result.unwrap_or_default(),
+        )?;
+
+        let note = JsonRpcRequest::notification(Method::Initialized, &serde_json::json!({}))?;
+        write_frame(&mut self.stdin, &serde_json::to_vec(¬e)?).await?;
+        Ok(())
+    }
+
+    /// Analyze one file, returning the list of entities that survive all
+    /// validations.
+    pub async fn analyze_file(
+        &mut self,
+        path: &Path,
+    ) -> Result, HostError> {
+        let _jailed = jail(&self.project_root, path).map_err(|e| match e {
+            JailError::EscapedRoot { .. } => HostError::PathEscapeBreakerTripped { count: 1 },
+            JailError::Canonicalise { source, .. } => HostError::Io(source),
+        })?;
+        let id = self.next_request_id();
+        let req = JsonRpcRequest::new(
+            id,
+            Method::AnalyzeFile,
+            &AnalyzeFileParams {
+                path: path.to_string_lossy().into_owned(),
+            },
+        )?;
+        write_frame(&mut self.stdin, &serde_json::to_vec(&req)?).await?;
+        let frame = read_frame(&mut self.stdout, self.ceiling.bytes()).await?;
+        let resp: JsonRpcResponse = serde_json::from_slice(&frame.body)?;
+        if let Some(err) = resp.error {
+            return Err(HostError::RpcError {
+                code: err.code,
+                message: err.message,
+            });
+        }
+        let result: AnalyzeFileResult = serde_json::from_value(
+            resp.result.unwrap_or_default(),
+        )?;
+
+        let mut accepted: Vec = Vec::with_capacity(result.entities.len());
+        for entity in result.entities {
+            match self.validate_entity(entity) {
+                Ok(Some(v)) => accepted.push(v),
+                Ok(None) => {} // dropped with logged finding
+                Err(e) => return Err(e),
+            }
+        }
+        self.entity_cap
+            .try_admit(accepted.len() as u64)
+            .map_err(HostError::from)?;
+        Ok(accepted)
+    }
+
+    fn validate_entity(
+        &mut self,
+        e: PluginEntity,
+    ) -> Result, HostError> {
+        // ADR-022: kind must be declared in the manifest's entity_kinds.
+        if !self
+            .manifest
+            .ontology
+            .entity_kinds
+            .iter()
+            .any(|k| k == &e.kind)
+        {
+            tracing::warn!(
+                rule_id = "CLA-INFRA-PLUGIN-UNDECLARED-KIND",
+                plugin = %self.manifest.plugin.name,
+                kind = %e.kind,
+                entity_id = %e.id,
+                "dropping entity with kind not declared in manifest"
+            );
+            return Ok(None);
+        }
+
+        // ADR-003 + UQ-WP2-11: id must match entity_id(plugin_id, kind, qualified_name).
+        // plugin_id narrows `clarion-plugin-` to `` with dashes
+        // replaced by underscores — the grammar in ADR-022 forbids dashes in
+        // plugin_id.
+        let derived_plugin_id = derive_plugin_id(&self.manifest.plugin.name);
+        let expected_id = match entity_id(&derived_plugin_id, &e.kind, &e.qualified_name) {
+            Ok(id) => id,
+            Err(err) => {
+                tracing::warn!(
+                    rule_id = "CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH",
+                    plugin = %self.manifest.plugin.name,
+                    entity_id = %e.id,
+                    error = %err,
+                    "dropping entity whose reconstruction failed"
+                );
+                return Ok(None);
+            }
+        };
+        if expected_id.as_str() != e.id || e.plugin_id != derived_plugin_id {
+            tracing::warn!(
+                rule_id = "CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH",
+                plugin = %self.manifest.plugin.name,
+                expected = %expected_id,
+                observed = %e.id,
+                "dropping entity with mismatched id"
+            );
+            return Ok(None);
+        }
+
+        // ADR-021 §2a: jail the source path.
+        let source_path = PathBuf::from(&e.source.file_path);
+        match jail(&self.project_root, &source_path) {
+            Ok(canonical) => {
+                let properties_json = if e.properties.is_null() {
+                    "{}".to_owned()
+                } else {
+                    serde_json::to_string(&e.properties)?
+                };
+                Ok(Some(ValidatedEntity {
+                    id: e.id,
+                    plugin_id: e.plugin_id,
+                    kind: e.kind,
+                    name: e.qualified_name.clone(),
+                    short_name: e.short_name,
+                    parent_id: e.parent_id,
+                    source_file: canonical,
+                    source_byte_start: e.source.byte_start,
+                    source_byte_end: e.source.byte_end,
+                    source_line_start: e.source.line_start,
+                    source_line_end: e.source.line_end,
+                    properties_json,
+                    content_hash: e.content_hash,
+                }))
+            }
+            Err(JailError::EscapedRoot { candidate, .. }) => {
+                tracing::warn!(
+                    rule_id = "CLA-INFRA-PLUGIN-PATH-ESCAPE",
+                    plugin = %self.manifest.plugin.name,
+                    offending_path = %candidate.display(),
+                    "dropping entity whose source path escapes project_root"
+                );
+                if self.escape_breaker.record_escape() == BreakerState::Tripped {
+                    let count = self.escape_breaker.events_in_window();
+                    tracing::warn!(
+                        rule_id = "CLA-INFRA-PLUGIN-DISABLED-PATH-ESCAPE",
+                        plugin = %self.manifest.plugin.name,
+                        count = count,
+                        "path-escape sub-breaker tripped; killing plugin"
+                    );
+                    let _ = self.child.start_kill();
+                    return Err(HostError::PathEscapeBreakerTripped { count });
+                }
+                Ok(None)
+            }
+            Err(JailError::Canonicalise { source, .. }) => Err(HostError::Io(source)),
+        }
+    }
+
+    /// Shut down the plugin cleanly: send `shutdown`, await the reply,
+    /// send `exit`, then wait for the child to exit with a 5s timeout.
+    pub async fn shutdown(mut self) -> Result<(), HostError> {
+        let id = self.next_request_id();
+        let req = JsonRpcRequest::new(id, Method::Shutdown, &ShutdownParams::default())?;
+        write_frame(&mut self.stdin, &serde_json::to_vec(&req)?).await?;
+        let _frame = read_frame(&mut self.stdout, self.ceiling.bytes()).await?;
+        let note = JsonRpcRequest::notification(Method::Exit, &serde_json::json!({}))?;
+        write_frame(&mut self.stdin, &serde_json::to_vec(¬e)?).await?;
+        drop(self.stdin);
+        match timeout(SHUTDOWN_TIMEOUT, self.child.wait()).await {
+            Ok(Ok(_)) => Ok(()),
+            Ok(Err(e)) => Err(HostError::Io(e)),
+            Err(_) => {
+                let _ = self.child.start_kill();
+                Err(HostError::ShutdownTimeout(SHUTDOWN_TIMEOUT))
+            }
+        }
+    }
+
+    fn next_request_id(&self) -> u64 {
+        self.next_id.fetch_add(1, Ordering::Relaxed)
+    }
+}
+
+/// Derive the ADR-022-compliant plugin_id from the manifest name.
+/// `clarion-plugin-python` → `python`. Dashes are replaced with
+/// underscores so the grammar `[a-z][a-z0-9_]*` is satisfied.
+fn derive_plugin_id(manifest_name: &str) -> String {
+    manifest_name
+        .strip_prefix("clarion-plugin-")
+        .unwrap_or(manifest_name)
+        .replace('-', "_")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn derive_plugin_id_strips_prefix() {
+        assert_eq!(derive_plugin_id("clarion-plugin-python"), "python");
+        assert_eq!(derive_plugin_id("clarion-plugin-foo-bar"), "foo_bar");
+    }
+}
+
+// EntityIdError is part of this module's public error surface reach —
+// keep the import live even if no variant is currently constructed here.
+#[doc(hidden)]
+fn _entity_id_error_type_reach(e: EntityIdError) -> EntityIdError {
+    e
+}
+```
+
+- [ ] **Step 6: Extend `plugin/mod.rs` to declare host**
+
+Add to `plugin/mod.rs`:
+
+```rust
+pub mod host;
+pub use host::{HostError, PluginHost, ValidatedEntity};
+```
+
+- [ ] **Step 7: Run host integration tests**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-core --test host_integration --no-tests=pass
+```
+
+Expected: 5 tests pass (`happy_path_handshake_and_analyze_file`, `undeclared_kind_entity_dropped`, `id_mismatch_entity_dropped`, `path_escape_drops_entity_plugin_stays_alive`, `eleven_escapes_trip_sub_breaker_and_kill`).
+
+If the last test flakes, it's usually one of:
+- The mock emits the 11 entities in one response; the host processes them in-order and trips on the 11th. If the test expects `err` but the host returned `Ok(vec![])` with all 11 dropped, the breaker `>` vs `>=` threshold is the bug.
+- The fixture's `repeat-path-escape 11` spawn arg didn't split properly. Check `PluginHost::spawn`'s `split_whitespace` flatten.
+
+- [ ] **Step 8: Full ADR-023 gate sweep + flake-check x3**
+
+```bash
+cd /home/john/clarion && cargo fmt --all -- --check
+cd /home/john/clarion && cargo clippy --workspace --all-targets --all-features -- -D warnings
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo doc --workspace --no-deps --all-features
+cd /home/john/clarion && cargo deny check
+```
+
+- [ ] **Step 9: Commit**
+
+```bash
+cd /home/john/clarion && git add Cargo.toml crates/ && git commit -m "$(cat <<'EOF'
+feat(wp2): plugin-host supervisor with ADR-021 enforcement + ADR-022 ontology
+
+plugin/host.rs — PluginHost::spawn(executable, args, manifest, project_root)
+applies RLIMIT_AS on spawn, pipes stdio, performs the L4 handshake, and
+exposes analyze_file + shutdown.
+
+Per-entity validation on each analyze_file response:
+  * ADR-022: kind must be in manifest.ontology.entity_kinds (drop + log
+    CLA-INFRA-PLUGIN-UNDECLARED-KIND).
+  * UQ-WP2-11: id must equal entity_id(plugin_id, kind, qualified_name)
+    (drop + log CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH).
+  * ADR-021 §2a: source.file_path must canonicalise inside project_root
+    (drop + log CLA-INFRA-PLUGIN-PATH-ESCAPE + tick sub-breaker; trip on
+    11th escape kills the plugin + returns HostError).
+  * ADR-021 §2c: per-run entity-count cap consulted after each batch.
+
+stderr is forwarded line-by-line to tracing::info! target plugin::stderr
+(UQ-WP2-07 resolution). shutdown consumes self, sends shutdown+exit, waits
+with a 5s timeout, SIGKILLs on timeout.
+
+crates/clarion-mock-plugin — new workspace fixture binary. Modes: compliant,
+undeclared-kind, id-mismatch, path-escape, repeat-path-escape , crash.
+Each writes JSON-RPC frames on stdout, reads from stdin, logs free-form to
+stderr. Host integration tests drive it via CARGO_BIN_EXE_clarion-mock-plugin.
+
+5 host integration tests: happy path, undeclared kind dropped, id mismatch
+dropped, single escape dropped + plugin alive, 11 escapes trip the
+sub-breaker and kill. 1 unit test for derive_plugin_id.
+EOF
+)"
+```
+
+---
+
+## Task 7: Crash-loop breaker
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-core/src/plugin/breaker.rs`
+- Modify: `/home/john/clarion/crates/clarion-core/src/plugin/mod.rs` (declare + re-export)
+
+- [ ] **Step 1: Extend `plugin/mod.rs`**
+
+Add:
+
+```rust
+pub mod breaker;
+pub use breaker::{CrashLoopBreaker, CrashLoopState, DEFAULT_CRASH_LIMIT, DEFAULT_CRASH_WINDOW};
+```
+
+- [ ] **Step 2: Write `breaker.rs` tests first**
+
+Create `/home/john/clarion/crates/clarion-core/src/plugin/breaker.rs`:
+
+```rust
+//! Per-plugin crash-loop breaker (ADR-002 + ADR-021 Layer 3).
+//!
+//! Default: >3 crashes in 60s trips the breaker and disables the plugin
+//! for the run. Sprint 1 hard-codes these values (UQ-WP2-10); config
+//! surface lives in `clarion.yaml:plugin_limits.*` from WP6 onwards.
+//!
+//! The breaker's only job is to answer "can I spawn this plugin right
+//! now?" and "record that this plugin just crashed". Actual spawn,
+//! kill, and finding emission are [`super::host`]'s concerns.
+
+use std::collections::VecDeque;
+use std::time::{Duration, Instant};
+
+pub const DEFAULT_CRASH_LIMIT: usize = 3;
+pub const DEFAULT_CRASH_WINDOW: Duration = Duration::from_secs(60);
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CrashLoopState {
+    /// Spawn is permitted.
+    Closed,
+    /// Breaker tripped — spawn refused until the window elapses.
+    Tripped,
+}
+
+#[derive(Debug, Clone)]
+pub struct CrashLoopBreaker {
+    limit: usize,
+    window: Duration,
+    events: VecDeque,
+}
+
+impl CrashLoopBreaker {
+    pub fn new(limit: usize, window: Duration) -> Self {
+        Self {
+            limit,
+            window,
+            events: VecDeque::new(),
+        }
+    }
+
+    /// Record a crash event at `now`. Returns the breaker state AFTER
+    /// recording: `Tripped` once `limit` is exceeded within `window`.
+    pub fn record_crash_at(&mut self, now: Instant) -> CrashLoopState {
+        let cutoff = now.checked_sub(self.window).unwrap_or(now);
+        while let Some(front) = self.events.front() {
+            if *front < cutoff {
+                self.events.pop_front();
+            } else {
+                break;
+            }
+        }
+        self.events.push_back(now);
+        self.state_at(now)
+    }
+
+    pub fn record_crash(&mut self) -> CrashLoopState {
+        self.record_crash_at(Instant::now())
+    }
+
+    /// Query current state without recording a new event.
+    pub fn state_at(&self, now: Instant) -> CrashLoopState {
+        let cutoff = now.checked_sub(self.window).unwrap_or(now);
+        let in_window = self.events.iter().filter(|&&t| t >= cutoff).count();
+        if in_window > self.limit {
+            CrashLoopState::Tripped
+        } else {
+            CrashLoopState::Closed
+        }
+    }
+
+    pub fn state(&self) -> CrashLoopState {
+        self.state_at(Instant::now())
+    }
+}
+
+impl Default for CrashLoopBreaker {
+    fn default() -> Self {
+        Self::new(DEFAULT_CRASH_LIMIT, DEFAULT_CRASH_WINDOW)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn fourth_crash_within_window_trips_at_default() {
+        let mut b = CrashLoopBreaker::default();
+        let t0 = Instant::now();
+        for i in 0..3 {
+            assert_eq!(
+                b.record_crash_at(t0 + Duration::from_millis(i * 10)),
+                CrashLoopState::Closed,
+                "crash {i} unexpectedly tripped"
+            );
+        }
+        assert_eq!(
+            b.record_crash_at(t0 + Duration::from_millis(100)),
+            CrashLoopState::Tripped,
+            "4th crash did not trip"
+        );
+    }
+
+    #[test]
+    fn crashes_outside_window_dont_count() {
+        let mut b = CrashLoopBreaker::new(2, Duration::from_secs(1));
+        let t0 = Instant::now();
+        assert_eq!(b.record_crash_at(t0), CrashLoopState::Closed);
+        assert_eq!(
+            b.record_crash_at(t0 + Duration::from_millis(500)),
+            CrashLoopState::Closed
+        );
+        // 1.6s later — first two expired; this is a single in-window event.
+        assert_eq!(
+            b.record_crash_at(t0 + Duration::from_millis(1_600)),
+            CrashLoopState::Closed
+        );
+    }
+
+    #[test]
+    fn query_state_without_recording() {
+        let mut b = CrashLoopBreaker::new(1, Duration::from_secs(60));
+        let t0 = Instant::now();
+        b.record_crash_at(t0);
+        b.record_crash_at(t0);
+        assert_eq!(b.state_at(t0), CrashLoopState::Tripped);
+    }
+}
+```
+
+- [ ] **Step 3: Run breaker tests; expect pass**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-core breaker --no-tests=pass
+```
+
+Expected: 3 tests pass.
+
+- [ ] **Step 4: Add an integration test using the crashing mock fixture**
+
+Append to `/home/john/clarion/crates/clarion-core/tests/host_integration.rs`:
+
+```rust
+#[tokio::test]
+async fn crashing_plugin_trips_breaker_across_spawns() {
+    use clarion_core::plugin::breaker::{CrashLoopBreaker, CrashLoopState};
+    use std::time::Duration;
+
+    let tmp = tempfile::tempdir().unwrap();
+    let mut breaker = CrashLoopBreaker::new(3, Duration::from_secs(60));
+
+    for i in 0..4 {
+        // Spawning the crash mock succeeds (handshake completes) but the
+        // plugin exits 1 immediately after. The host's child handle goes
+        // to "exited" next poll.
+        let host_result = spawn_with_mode("crash", tmp.path()).await;
+        drop(host_result); // plugin has already exited by now
+        let state = breaker.record_crash();
+        if i < 3 {
+            assert_eq!(state, CrashLoopState::Closed);
+        } else {
+            assert_eq!(state, CrashLoopState::Tripped);
+        }
+    }
+}
+```
+
+Note: the `crash` mode in the fixture exits 1 immediately after handshake, so `spawn_with_mode("crash", ...)` returns a `PluginHost` whose child has already exited. The test doesn't exercise the host's kill path — it just verifies the breaker's arithmetic across repeated spawn attempts. This matches the spec: Sprint 1's breaker is scaffolding; "a unit test proves the breaker trips".
+
+- [ ] **Step 5: Run the updated integration test**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-core --test host_integration --no-tests=pass
+cd /home/john/clarion && cargo nextest run -p clarion-core --test host_integration --no-tests=pass
+cd /home/john/clarion && cargo nextest run -p clarion-core --test host_integration --no-tests=pass
+```
+
+Three runs for timing flake check. All 6 integration tests should pass each run.
+
+- [ ] **Step 6: Full ADR-023 gate sweep**
+
+```bash
+cd /home/john/clarion && cargo fmt --all -- --check
+cd /home/john/clarion && cargo clippy --workspace --all-targets --all-features -- -D warnings
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo doc --workspace --no-deps --all-features
+cd /home/john/clarion && cargo deny check
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-core/ && git commit -m "$(cat <<'EOF'
+feat(wp2): crash-loop breaker
+
+plugin/breaker.rs — CrashLoopBreaker with ADR-002 / ADR-021 Layer 3
+defaults: >3 crashes in 60s trips. Rolling window via VecDeque;
+synthetic timestamps in unit tests keep the arithmetic deterministic.
+
+3 unit tests: 4th-crash-trips, outside-window-resets, state-query-no-record.
+1 added integration test: crashing mock binary spawned 4 times; breaker
+records each crash; 4th record returns Tripped.
+
+Sprint 1 scope per UQ-WP2-10: the breaker is a pure counter today;
+spawn-refusal wiring is Task 8/WP6. The data structure and thresholds
+are locked; wiring is the next layer up.
+EOF
+)"
+```
+
+---
+
+## Task 8: Wire `clarion analyze` to use the plugin host
+
+**Files:**
+- Modify: `/home/john/clarion/crates/clarion-cli/Cargo.toml` (add `clarion-core` plugin surface, `walkdir`)
+- Modify: `/home/john/clarion/crates/clarion-cli/src/analyze.rs` (replace Sprint-1 stub)
+- Modify: `/home/john/clarion/Cargo.toml` (add `walkdir` workspace dep)
+- Create: `/home/john/clarion/crates/clarion-cli/tests/analyze_with_plugin.rs`
+
+- [ ] **Step 1: Add `walkdir` workspace dep**
+
+Append to `[workspace.dependencies]`:
+
+```toml
+walkdir = "2"
+```
+
+- [ ] **Step 2: Extend `clarion-cli/Cargo.toml`**
+
+Add to `[dependencies]`:
+
+```toml
+walkdir.workspace = true
+```
+
+Nothing else needs adding — `clarion-core` is already a path dependency and the plugin module is exposed at the crate root through the re-exports in `plugin/mod.rs`.
+
+- [ ] **Step 3: Rewrite `analyze.rs` to use the plugin host**
+
+Replace `/home/john/clarion/crates/clarion-cli/src/analyze.rs` (keep the helpers `iso8601_now` + `civil_from_unix_secs` from Sprint 1 — they're still needed for timestamps):
+
+```rust
+//! `clarion analyze` — WP2-wired walking skeleton.
+//!
+//! Discovers plugins via [`clarion_core::plugin::discover`], spawns each,
+//! walks the project tree, calls `analyze_file` per matching file, and
+//! persists returned entities through the WP1 writer-actor.
+
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result, bail};
+use uuid::Uuid;
+use walkdir::WalkDir;
+
+use clarion_core::plugin::host::{PluginHost, ValidatedEntity};
+use clarion_core::plugin::{discovery, manifest::Manifest};
+use clarion_storage::{
+    DEFAULT_BATCH_SIZE, DEFAULT_CHANNEL_CAPACITY, EntityRecord, RunStatus, Writer,
+    commands::WriterCmd,
+};
+
+/// Run the analyze command against `project_path`.
+///
+/// # Errors
+///
+/// Returns an error if the target directory does not exist, has no
+/// `.clarion/` directory, or if any subsystem (discovery, spawn,
+/// writer-actor) fails fatally.
+pub async fn run(project_path: PathBuf) -> Result<()> {
+    if !project_path.exists() {
+        bail!(
+            "target directory does not exist: {}. Pass a valid path or cd to it first.",
+            project_path.display()
+        );
+    }
+    let project_root = project_path
+        .canonicalize()
+        .with_context(|| format!("cannot canonicalise path {}", project_path.display()))?;
+    let clarion_dir = project_root.join(".clarion");
+    if !clarion_dir.exists() {
+        bail!(
+            "{} has no .clarion/ directory. Run `clarion install` first.",
+            project_root.display()
+        );
+    }
+    let db_path = clarion_dir.join("clarion.db");
+
+    let plugins = discovery::discover().context("plugin discovery")?;
+
+    let (writer, handle) = Writer::spawn(db_path, DEFAULT_BATCH_SIZE, DEFAULT_CHANNEL_CAPACITY)
+        .map_err(|e| anyhow::anyhow!("{e}"))
+        .context("spawn writer actor")?;
+    let run_id = Uuid::new_v4().to_string();
+    let started_at = iso8601_now();
+
+    writer
+        .send_wait(|ack| WriterCmd::BeginRun {
+            run_id: run_id.clone(),
+            config_json: "{}".into(),
+            started_at: started_at.clone(),
+            ack,
+        })
+        .await
+        .map_err(|e| anyhow::anyhow!("{e}"))
+        .context("BeginRun")?;
+
+    let mut total_entities: u64 = 0;
+    let mut any_failure = false;
+
+    if plugins.is_empty() {
+        tracing::warn!(run_id = %run_id, "no plugins discovered on PATH");
+    }
+
+    for plugin in plugins {
+        match drive_plugin(&plugin, &project_root, &writer, &run_id).await {
+            Ok(count) => {
+                total_entities += count;
+                tracing::info!(
+                    plugin = %plugin.manifest.plugin.name,
+                    entities = count,
+                    "plugin finished"
+                );
+            }
+            Err(e) => {
+                any_failure = true;
+                tracing::error!(
+                    plugin = %plugin.manifest.plugin.name,
+                    error = %e,
+                    "plugin failed; continuing with remaining plugins"
+                );
+            }
+        }
+    }
+
+    let completed_at = iso8601_now();
+    let status = if any_failure {
+        RunStatus::Failed
+    } else if total_entities == 0 {
+        RunStatus::SkippedNoPlugins
+    } else {
+        RunStatus::Completed
+    };
+    let stats = format!(r#"{{"entities_inserted":{total_entities}}}"#);
+    let cmd_status = status;
+    writer
+        .send_wait(move |ack| WriterCmd::CommitRun {
+            run_id: run_id.clone(),
+            status: cmd_status,
+            completed_at: completed_at.clone(),
+            stats_json: stats.clone(),
+            ack,
+        })
+        .await
+        .map_err(|e| anyhow::anyhow!("{e}"))
+        .context("CommitRun")?;
+
+    drop(writer);
+    handle
+        .await
+        .map_err(|e| anyhow::anyhow!("writer actor panic: {e}"))?
+        .map_err(|e| anyhow::anyhow!("{e}"))?;
+
+    println!("analyze complete: status {}", status.as_str());
+    Ok(())
+}
+
+async fn drive_plugin(
+    plugin: &discovery::DiscoveredPlugin,
+    project_root: &Path,
+    writer: &Writer,
+    _run_id: &str,
+) -> Result {
+    let mut host = PluginHost::spawn(
+        plugin.executable.clone(),
+        vec![],
+        plugin.manifest.clone(),
+        project_root.to_path_buf(),
+    )
+    .await
+    .map_err(|e| anyhow::anyhow!("{e}"))
+    .context("plugin spawn")?;
+
+    let mut count: u64 = 0;
+    for file in walk_files(project_root, &plugin.manifest) {
+        match host.analyze_file(&file).await {
+            Ok(entities) => {
+                for v in entities {
+                    persist_entity(writer, v).await?;
+                    count += 1;
+                }
+            }
+            Err(e) => {
+                tracing::warn!(
+                    plugin = %plugin.manifest.plugin.name,
+                    file = %file.display(),
+                    error = %e,
+                    "analyze_file failed; skipping file"
+                );
+            }
+        }
+    }
+
+    host.shutdown()
+        .await
+        .map_err(|e| anyhow::anyhow!("{e}"))
+        .context("plugin shutdown")?;
+    Ok(count)
+}
+
+fn walk_files(project_root: &Path, manifest: &Manifest) -> Vec {
+    let exts: Vec<&str> = manifest
+        .plugin
+        .extensions
+        .iter()
+        .map(String::as_str)
+        .collect();
+    WalkDir::new(project_root)
+        .into_iter()
+        .filter_map(std::result::Result::ok)
+        .filter(|entry| entry.file_type().is_file())
+        .filter_map(|entry| {
+            let path = entry.path();
+            let ext = path.extension()?.to_str()?;
+            if exts.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
+                Some(path.to_path_buf())
+            } else {
+                None
+            }
+        })
+        // Skip anything inside the .clarion/ state directory.
+        .filter(|p| !p.components().any(|c| c.as_os_str() == ".clarion"))
+        .collect()
+}
+
+async fn persist_entity(writer: &Writer, v: ValidatedEntity) -> Result<()> {
+    let now = iso8601_now();
+    let record = EntityRecord {
+        id: v.id,
+        plugin_id: v.plugin_id,
+        kind: v.kind,
+        name: v.name,
+        short_name: v.short_name,
+        parent_id: v.parent_id,
+        source_file_id: Some(v.source_file.to_string_lossy().into_owned()),
+        source_byte_start: v.source_byte_start,
+        source_byte_end: v.source_byte_end,
+        source_line_start: v.source_line_start,
+        source_line_end: v.source_line_end,
+        properties_json: v.properties_json,
+        content_hash: v.content_hash,
+        summary_json: None,
+        wardline_json: None,
+        first_seen_commit: None,
+        last_seen_commit: None,
+        created_at: now.clone(),
+        updated_at: now,
+    };
+    writer
+        .send_wait(|ack| WriterCmd::InsertEntity {
+            entity: Box::new(record),
+            ack,
+        })
+        .await
+        .map_err(|e| anyhow::anyhow!("{e}"))
+        .context("InsertEntity")
+}
+
+fn iso8601_now() -> String {
+    use std::time::{SystemTime, UNIX_EPOCH};
+    let d = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .expect("SystemTime before UNIX epoch");
+    let secs = d.as_secs();
+    let millis = d.subsec_millis();
+    let (y, mo, da, h, mi, se) = civil_from_unix_secs(secs);
+    format!("{y:04}-{mo:02}-{da:02}T{h:02}:{mi:02}:{se:02}.{millis:03}Z")
+}
+
+fn civil_from_unix_secs(mut secs: u64) -> (u32, u32, u32, u32, u32, u32) {
+    let se = u32::try_from(secs % 60).expect("modulo 60 fits in u32");
+    secs /= 60;
+    let mi = u32::try_from(secs % 60).expect("modulo 60 fits in u32");
+    secs /= 60;
+    let h = u32::try_from(secs % 24).expect("modulo 24 fits in u32");
+    secs /= 24;
+    let days = i64::try_from(secs).expect("days since epoch fits in i64");
+    let z = days + 719_468;
+    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+    let doe = u64::try_from(z - era * 146_097).expect("day-of-era is non-negative");
+    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
+    let y_shifted = i64::try_from(yoe).expect("year-of-era fits in i64") + era * 400;
+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+    let mp = (5 * doy + 2) / 153;
+    let da = u32::try_from(doy - (153 * mp + 2) / 5 + 1).expect("day-of-month fits in u32");
+    let mo = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).expect("month fits in u32");
+    let y_i64 = if mo <= 2 { y_shifted + 1 } else { y_shifted };
+    let y = u32::try_from(y_i64).expect("year fits in u32 (post-1970)");
+    (y, mo, da, h, mi, se)
+}
+```
+
+- [ ] **Step 4: Write the plugin-wired integration test**
+
+Create `/home/john/clarion/crates/clarion-cli/tests/analyze_with_plugin.rs`:
+
+```rust
+//! `clarion analyze` with a mock plugin on PATH produces persisted entities.
+//!
+//! Assembles a temporary `$PATH` containing the `clarion-mock-plugin`
+//! fixture binary and a neighboring `plugin.toml`. Runs `clarion install`
+//! then `clarion analyze` and asserts the DB row shape.
+
+use std::fs;
+use std::path::PathBuf;
+
+use assert_cmd::Command;
+use rusqlite::Connection;
+
+fn clarion_bin() -> Command {
+    Command::cargo_bin("clarion").expect("clarion binary")
+}
+
+fn mock_plugin_bin() -> PathBuf {
+    PathBuf::from(env!("CARGO_BIN_EXE_clarion-mock-plugin"))
+}
+
+fn mock_manifest_bytes() -> Vec {
+    let fixture = concat!(
+        env!("CARGO_MANIFEST_DIR"),
+        "/../clarion-mock-plugin/fixtures/plugin.toml"
+    );
+    std::fs::read(fixture).expect("read mock manifest fixture")
+}
+
+#[test]
+fn analyze_with_mock_plugin_persists_entities() {
+    let project_dir = tempfile::tempdir().unwrap();
+    let bin_dir = tempfile::tempdir().unwrap();
+
+    // Symlink the real mock-plugin binary into bin_dir under the expected name.
+    let symlinked = bin_dir.path().join("clarion-plugin-mock");
+    #[cfg(unix)]
+    std::os::unix::fs::symlink(mock_plugin_bin(), &symlinked).unwrap();
+    #[cfg(not(unix))]
+    compile_error!("WP2 Sprint 1 is Linux-only; this test requires symlinks");
+
+    // Neighboring manifest.
+    fs::write(bin_dir.path().join("plugin.toml"), mock_manifest_bytes()).unwrap();
+
+    // One `.mock` file for the plugin to pick up.
+    fs::write(project_dir.path().join("demo.mock"), b"sample\n").unwrap();
+
+    // clarion install
+    clarion_bin()
+        .args(["install", "--path"])
+        .arg(project_dir.path())
+        .assert()
+        .success();
+
+    // clarion analyze with our custom PATH
+    clarion_bin()
+        .env("PATH", bin_dir.path())
+        .args(["analyze"])
+        .arg(project_dir.path())
+        .assert()
+        .success();
+
+    // Assert the DB row shape.
+    let db = project_dir.path().join(".clarion").join("clarion.db");
+    let conn = Connection::open(&db).unwrap();
+    let (count, status): (i64, String) = conn
+        .query_row(
+            "SELECT COUNT(*), COALESCE(MAX(status), '') FROM runs",
+            [],
+            |row| Ok((row.get(0)?, row.get(1)?)),
+        )
+        .unwrap();
+    assert_eq!(count, 1);
+    assert_eq!(status, "completed");
+
+    let entity_count: i64 = conn
+        .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0))
+        .unwrap();
+    assert!(entity_count >= 1, "expected at least one entity; got {entity_count}");
+
+    let id: String = conn
+        .query_row("SELECT id FROM entities LIMIT 1", [], |row| row.get(0))
+        .unwrap();
+    assert!(
+        id.starts_with("mock:function:"),
+        "entity id does not match the fixture mock's emission: {id}"
+    );
+}
+```
+
+**Integration-test machinery caveat:** `Command::cargo_bin("clarion")` requires the `clarion` binary to exist in a known location. `CARGO_BIN_EXE_clarion-mock-plugin` is set automatically because `clarion-cli` depends on `clarion-mock-plugin` via its dev-dependencies only if declared. Add the fixture as a dev-dependency of `clarion-cli` so the env var is populated:
+
+Modify `/home/john/clarion/crates/clarion-cli/Cargo.toml` `[dev-dependencies]`:
+
+```toml
+[dev-dependencies]
+assert_cmd.workspace = true
+clarion-mock-plugin = { path = "../clarion-mock-plugin", version = "0.1.0-dev" }
+rusqlite.workspace = true
+serde_json.workspace = true
+tempfile.workspace = true
+```
+
+- [ ] **Step 5: Run the analyze tests**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-cli --no-tests=pass
+```
+
+Expected: all `clarion-cli` tests pass, including the new `analyze_with_mock_plugin_persists_entities` (plus WP1's pre-existing install + analyze tests).
+
+- [ ] **Step 6: Full ADR-023 gate sweep**
+
+```bash
+cd /home/john/clarion && cargo fmt --all -- --check
+cd /home/john/clarion && cargo clippy --workspace --all-targets --all-features -- -D warnings
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo doc --workspace --no-deps --all-features
+cd /home/john/clarion && cargo deny check
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /home/john/clarion && git add Cargo.toml crates/ && git commit -m "$(cat <<'EOF'
+feat(wp2): wire clarion analyze to plugin host
+
+analyze.rs — replaces the Sprint-1 skipped_no_plugins stub. Discovers
+plugins via clarion_core::plugin::discover, spawns each PluginHost, walks
+the project tree filtering by the manifest's [plugin].extensions, calls
+analyze_file per file, and persists each ValidatedEntity via the WP1
+writer-actor InsertEntity path. Per-plugin shutdown is clean (shutdown +
+exit + 5s wait).
+
+Run status wiring:
+  * Completed when at least one entity persisted and no plugin failed.
+  * SkippedNoPlugins when discovery returned empty or no entities emerged.
+  * Failed when any plugin errored fatally (the other plugins still run;
+    this matches "partial-results" framing from ADR-021 §2c).
+
+walkdir = "2" added as a workspace dep. clarion-mock-plugin added as a
+dev-dependency of clarion-cli so integration tests get the fixture's
+CARGO_BIN_EXE_ env var.
+
+1 new integration test assembles a temp PATH dir with a symlinked
+clarion-mock-plugin + neighboring plugin.toml, runs clarion install +
+analyze, and asserts status=completed + entities >= 1 + id prefix
+"mock:function:".
+EOF
+)"
+```
+
+---
+
+## Task 9: WP2 end-to-end smoke test
+
+**Files:**
+- Create: `/home/john/clarion/crates/clarion-cli/tests/wp2_e2e.rs`
+
+- [ ] **Step 1: Write the E2E smoke test**
+
+Create `/home/john/clarion/crates/clarion-cli/tests/wp2_e2e.rs`:
+
+```rust
+//! WP2 end-to-end smoke test — mirrors the README §3 demo script at WP2
+//! scope (real plugin host + mock plugin, entities persisted end-to-end).
+
+use std::fs;
+use std::path::PathBuf;
+
+use assert_cmd::Command;
+use rusqlite::Connection;
+
+fn clarion_bin() -> Command {
+    Command::cargo_bin("clarion").expect("clarion binary")
+}
+
+fn mock_plugin_bin() -> PathBuf {
+    PathBuf::from(env!("CARGO_BIN_EXE_clarion-mock-plugin"))
+}
+
+fn mock_manifest_bytes() -> Vec {
+    std::fs::read(concat!(
+        env!("CARGO_MANIFEST_DIR"),
+        "/../clarion-mock-plugin/fixtures/plugin.toml"
+    ))
+    .expect("read mock manifest")
+}
+
+#[test]
+fn wp2_walking_skeleton_end_to_end() {
+    let project = tempfile::tempdir().unwrap();
+    let path_dir = tempfile::tempdir().unwrap();
+
+    // Seed the project with two .mock files so we exercise the per-file loop.
+    fs::write(project.path().join("a.mock"), b"a\n").unwrap();
+    fs::write(project.path().join("b.mock"), b"b\n").unwrap();
+    // And one non-matching file the plugin should skip.
+    fs::write(project.path().join("README.txt"), b"readme\n").unwrap();
+
+    // Install the plugin into path_dir + neighboring manifest.
+    #[cfg(unix)]
+    std::os::unix::fs::symlink(
+        mock_plugin_bin(),
+        path_dir.path().join("clarion-plugin-mock"),
+    )
+    .unwrap();
+    fs::write(path_dir.path().join("plugin.toml"), mock_manifest_bytes()).unwrap();
+
+    // clarion install
+    clarion_bin()
+        .args(["install", "--path"])
+        .arg(project.path())
+        .assert()
+        .success();
+
+    let clarion_dir = project.path().join(".clarion");
+    assert!(clarion_dir.join("clarion.db").exists());
+    assert!(clarion_dir.join("config.json").exists());
+    assert!(clarion_dir.join(".gitignore").exists());
+    assert!(project.path().join("clarion.yaml").exists());
+
+    // clarion analyze with the mock plugin on PATH
+    clarion_bin()
+        .env("PATH", path_dir.path())
+        .args(["analyze"])
+        .arg(project.path())
+        .assert()
+        .success();
+
+    let conn = Connection::open(clarion_dir.join("clarion.db")).unwrap();
+
+    let migration_version: i64 = conn
+        .query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
+            row.get(0)
+        })
+        .unwrap();
+    assert_eq!(migration_version, 1);
+
+    let runs_count: i64 = conn
+        .query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0))
+        .unwrap();
+    assert_eq!(runs_count, 1);
+
+    let run_status: String = conn
+        .query_row("SELECT status FROM runs", [], |row| row.get(0))
+        .unwrap();
+    assert_eq!(run_status, "completed");
+
+    let entity_count: i64 = conn
+        .query_row("SELECT COUNT(*) FROM entities", [], |row| row.get(0))
+        .unwrap();
+    assert_eq!(entity_count, 2, "two .mock files should produce 2 entities");
+
+    // Assert the 3-segment ID shape matches L2 + the fixture emission.
+    let kinds: Vec<(String, String)> = conn
+        .prepare("SELECT id, kind FROM entities")
+        .unwrap()
+        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
+        .unwrap()
+        .collect::>()
+        .unwrap();
+    for (id, kind) in kinds {
+        assert!(id.starts_with("mock:function:"), "id shape: {id}");
+        assert_eq!(kind, "function");
+    }
+}
+```
+
+- [ ] **Step 2: Run the E2E test three times (flake check)**
+
+```bash
+cd /home/john/clarion && cargo nextest run -p clarion-cli --test wp2_e2e --no-tests=pass
+cd /home/john/clarion && cargo nextest run -p clarion-cli --test wp2_e2e --no-tests=pass
+cd /home/john/clarion && cargo nextest run -p clarion-cli --test wp2_e2e --no-tests=pass
+```
+
+- [ ] **Step 3: Release-profile build**
+
+```bash
+cd /home/john/clarion && cargo build --workspace --release
+```
+
+Any warning-as-error surfacing here must be fixed in-code, not by loosening lints.
+
+- [ ] **Step 4: Final ADR-023 gate sweep (all 7 gates)**
+
+```bash
+cd /home/john/clarion && cargo fmt --all -- --check
+cd /home/john/clarion && cargo clippy --workspace --all-targets --all-features -- -D warnings
+cd /home/john/clarion && cargo nextest run --workspace --all-features --no-tests=pass
+cd /home/john/clarion && cargo doc --workspace --no-deps --all-features
+cd /home/john/clarion && cargo deny check
+cd /home/john/clarion && cargo build --workspace
+cd /home/john/clarion && cargo build --workspace --release
+```
+
+All 7 exit 0. This is the WP2 closing-commit gate; CI runs the same set.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/john/clarion && git add crates/clarion-cli/ && git commit -m "$(cat <<'EOF'
+test(wp2): end-to-end smoke with mock plugin
+
+wp2_e2e.rs runs the README §3 demo script at WP2 scope: clarion install
++ clarion analyze against a project containing 2 .mock files, with the
+clarion-plugin-mock fixture on a synthetic PATH + neighboring plugin.toml.
+Asserts runs.status = 'completed', entity_count = 2, all IDs are the
+3-segment form `mock:function:` per L2.
+
+WP3 extends this test with the Python plugin's real emission; the
+assertions here carry forward unchanged (status + id shape + count).
+EOF
+)"
+```
+
+---
+
+## Task 10: Sign-off ladder + lock-in stamps + UQ resolutions
+
+**Files:**
+- Modify: `/home/john/clarion/docs/implementation/sprint-1/signoffs.md`
+- Modify: `/home/john/clarion/docs/implementation/sprint-1/README.md`
+- Modify: `/home/john/clarion/docs/implementation/sprint-1/wp2-plugin-host.md`
+
+- [ ] **Step 1: Tick Tier A.2 boxes in `signoffs.md`**
+
+For each checkbox A.2.1 through A.2.9, change `- [ ]` to `- [x]` after verifying the cited proof. For lock-in rows A.2.1 (L4), A.2.2 (L5), A.2.3 (L6), A.2.4 (L9), fill in `locked on ` with the closing-commit date (`git log -1 --format=%as HEAD`).
+
+Do NOT tick A.3 / A.4 / A.5 / A.6 — those belong to WP3 / sprint-close.
+
+- [ ] **Step 2: Stamp lock-in dates in `README.md` §4**
+
+In `/home/john/clarion/docs/implementation/sprint-1/README.md` §4 "Lock-in summary", annotate L4, L5, L6, L9 with the same `locked on ` stamp used in signoffs.md.
+
+- [ ] **Step 3: Mark UQ-WP2-* resolved in `wp2-plugin-host.md §5`**
+
+For each UQ-WP2-01 through UQ-WP2-11, append a `**Resolved**: ` line:
+
+- UQ-WP2-01 — resolved in Task 5: PATH + neighboring `plugin.toml` (share-dir fallback). First-match-wins on duplicates.
+- UQ-WP2-02 — resolved in Task 2: hand-rolled framing over `serde_json`.
+- UQ-WP2-03 — resolved in Task 4: canonicalise via `std::fs::canonicalize` (follows symlinks); symlinks inside the root resolving outside are rejected.
+- UQ-WP2-04 — resolved in Task 4: 8 MiB default, 1 MiB floor.
+- UQ-WP2-05 — resolved in Task 4: per-run combined `entity + edge + finding`, 500k default, 10k floor.
+- UQ-WP2-06 — resolved in Task 4: `#[cfg(target_os = "linux")]`-gated prlimit; non-Linux logs a one-shot warning. The macOS `setrlimit(RLIMIT_AS)` path lands with whichever sprint first adds macOS CI.
+- UQ-WP2-07 — resolved in Task 3/Task 6: stderr forwarded line-by-line to `tracing::info!` target `plugin::stderr`; progress notifications deferred.
+- UQ-WP2-08 — resolved in Task 3 (docs): plugin-author discipline; documented in the mock plugin's module rustdoc and inherited by WP3's plugin-author guide.
+- UQ-WP2-09 — resolved in Task 6: manifest is re-parsed on every `PluginHost::spawn`. Caching is a `serve` concern (WP8).
+- UQ-WP2-10 — resolved in Task 7: >3 crashes/60s crash-loop breaker + >10 escapes/60s path-escape sub-breaker hard-coded; config surface deferred to WP6.
+- UQ-WP2-11 — resolved in Task 6: host reconstructs `entity_id(derived_plugin_id, kind, qualified_name)` and compares against the returned `id`; mismatch drops the entity, logs `CLA-INFRA-PLUGIN-ENTITY-ID-MISMATCH`, plugin stays alive.
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /home/john/clarion && git add docs/implementation/sprint-1/ && git commit -m "$(cat <<'EOF'
+docs(sprint-1): tick WP2 sign-off and stamp L4/L5/L6/L9 lock-ins
+
+Tier A.2 boxes ticked in signoffs.md with the WP2 closing-commit date.
+README.md §4 lock-in table stamped for L4/L5/L6/L9. wp2-plugin-host.md §5
+UQ resolutions recorded inline with the resolving task and outcome.
+
+WP2 complete; WP3 (Python plugin) is now unblocked.
+EOF
+)"
+```
+
+---
+
+## Self-review summary
+
+**Spec coverage vs `wp2-plugin-host.md`:**
+
+| Spec task | Plan task | Status |
+|---|---|---|
+| §6.Task 1 Manifest parser (L5) | Task 1 | ✓ |
+| §6.Task 2 JSON-RPC transport (L4) | Task 2 | ✓ |
+| §6.Task 3 In-process mock plugin | Task 3 | ✓ |
+| §6.Task 4 Core-enforced minimums (L6) | Task 4 | ✓ |
+| §6.Task 5 Plugin discovery (L9) | Task 5 | ✓ |
+| §6.Task 6 Plugin-host supervisor | Task 6 | ✓ |
+| §6.Task 7 Crash-loop breaker | Task 7 | ✓ |
+| §6.Task 8 Wire `clarion analyze` | Task 8 | ✓ |
+| §6.Task 9 E2E smoke | Task 9 | ✓ |
+| §8 Exit criteria sign-off | Task 10 | ✓ |
+
+Every lock-in (L4/L5/L6/L9) has a dedicated Task that lands it. Every UQ-WP2-* has a designated resolving Task. Every exit-criteria bullet has a verification step.
+
+**Type-consistency spot-check:**
+
+- `PluginHost::spawn(executable, extra_args, manifest, project_root)` signature identical between Task 6 definition and Task 8 call site.
+- `ValidatedEntity` shape identical between Task 6 emission and Task 8 `EntityRecord` translation (Task 8's `persist_entity` maps field-for-field).
+- `ContentLengthCeiling::default()` = 8 MiB; `read_frame(reader, ceiling.bytes())` called with the struct's `bytes()` accessor — no `usize` literal drift.
+- `EntityCountCap::new(DEFAULT_ENTITY_COUNT_CAP)` uses the same const in Task 4 and Task 6.
+- `entity_id()` (WP1 Task 2) and Task 6's `derive_plugin_id` produce a plugin_id satisfying ADR-022 grammar; Task 6 tests explicitly exercise `clarion-plugin-foo-bar` → `foo_bar`.
+- `Method::{Initialize, Initialized, AnalyzeFile, Shutdown, Exit}` — same five variants in `protocol.rs` (Task 2), `mock.rs` (Task 3), `host.rs` (Task 6), and the fixture binary's `main.rs` (Task 6).
+- `WriterCmd::InsertEntity { entity: Box, ack }` — Task 8's `persist_entity` wraps the record in `Box::new(record)` per WP1's L3 locked shape.
+
+**Placeholder scan:** no `TODO` / `TBD` / `implement later` / "Similar to Task N" in the plan body. Every code step has a complete code block; every command step has a literal command + expected output. Every task ends with an explicit commit command using HEREDOC formatting.
+
+**Divergences the executor should know about:**
+
+1. **`unsafe_code` workspace lint relaxed from `"forbid"` to `"deny"`**. Task 4 introduces a single audited `#[allow(unsafe_code)]` at `plugin/limits.rs::apply_prlimit_as` with a safety-justifying comment. This is the ONLY unsafe call site in the workspace. A code reviewer on Task 4 should verify:
+   - No other module uses `#[allow(unsafe_code)]`.
+   - The safety comment addresses fork-safety (post-fork, pre-exec, async-signal-safe only).
+   - `setrlimit` is on the POSIX.1-2017 §2.4.3 AS-safe list.
+
+2. **`plugin_id` derivation narrowing**. Manifest `plugin.name` uses grammar `[a-z][a-z0-9_-]*` (dashes permitted) because it doubles as the PATH binary name. ADR-022's EntityId grammar for the `plugin_id` segment is stricter: `[a-z][a-z0-9_]*`. The host derives `plugin_id = manifest.name.strip_prefix("clarion-plugin-").replace('-', '_')`. This is a WP2 convention not stated verbatim in ADR-022; Task 6's commit message cites the derivation, and Task 10 should mention it in the UQ-WP2-11 resolution line.
+
+3. **`source_file_id` carries the canonical file path string, not a file-entity ID**. WP1's `EntityRecord.source_file_id: Option` is a foreign-key placeholder. Task 8's `persist_entity` puts the canonical file path string into it. WP4/WP5's file-discovery pass will replace that placeholder with the real core-minted file-entity ID; the schema column is permissive (TEXT). Flag to the design-doc author post-Sprint-1 if a stricter foreign-key constraint is wanted.
+
+4. **Sprint 1 findings are log-only**. The `CLA-INFRA-PLUGIN-*` rule IDs are emitted via `tracing::warn!(rule_id = "...", ...)` rather than as DB `findings` rows. WP6's scanner-ingest API (ADR-013) and the `POST /api/v1/scan-results` Filigree endpoint are where these logs become persisted findings. The rule IDs and field names locked in Task 6 are the forward-compatible contract.
+
+5. **`nix = "0.28"` with `features = ["resource"]` + `default-features = false`** — minimises the dependency surface. Older nix versions had a different `setrlimit` signature; the plan pins 0.28 exactly. If a reviewer bumps this, re-verify the pre_exec closure compiles.
+
+6. **Fixture binary lives under `crates/clarion-mock-plugin/`** as a full workspace member, not an example. Reason: `assert_cmd::Command::cargo_bin` requires a real bin target, and `CARGO_BIN_EXE_clarion-mock-plugin` is only set when the consuming crate declares it as a dev-dependency. Tasks 6 + 8 both declare it.
+
+---
+
+**Plan complete and saved to `docs/superpowers/plans/2026-04-18-wp2-plugin-host.md`.**
+
+Two execution options:
+
+1. **Subagent-Driven (recommended)** — Fresh implementer subagent per task, two-stage review (spec compliance first, then code quality), fix subagents stacked without amending. Matches WP1's review-looped 18-commit history.
+2. **Inline Execution** — Batch execution in the current session with checkpoints.
+
+**Recommendation:** Subagent-Driven. Task 4 (unsafe relaxation + fork-safety) and Task 6 (host supervisor with five enforcement concerns) benefit most from independent code-quality review.
+
+
+
diff --git a/fixtures/entity_id.json b/fixtures/entity_id.json
new file mode 100644
index 00000000..1d4f187a
--- /dev/null
+++ b/fixtures/entity_id.json
@@ -0,0 +1,142 @@
+[
+  {
+    "description": "simple module-level function",
+    "plugin_id": "python",
+    "kind": "function",
+    "canonical_qualified_name": "demo.hello",
+    "expected_entity_id": "python:function:demo.hello"
+  },
+  {
+    "description": "class method",
+    "plugin_id": "python",
+    "kind": "function",
+    "canonical_qualified_name": "demo.Foo.bar",
+    "expected_entity_id": "python:function:demo.Foo.bar"
+  },
+  {
+    "description": "nested function carries Python  marker",
+    "plugin_id": "python",
+    "kind": "function",
+    "canonical_qualified_name": "demo.outer..inner",
+    "expected_entity_id": "python:function:demo.outer..inner"
+  },
+  {
+    "description": "deeply nested function chains  markers",
+    "plugin_id": "python",
+    "kind": "function",
+    "canonical_qualified_name": "demo.a..b..c",
+    "expected_entity_id": "python:function:demo.a..b..c"
+  },
+  {
+    "description": "function nested in class method",
+    "plugin_id": "python",
+    "kind": "function",
+    "canonical_qualified_name": "demo.Foo.bar..inner",
+    "expected_entity_id": "python:function:demo.Foo.bar..inner"
+  },
+  {
+    "description": "class in function in class (single  at function boundary)",
+    "plugin_id": "python",
+    "kind": "function",
+    "canonical_qualified_name": "demo.Foo.bar..Local.meth",
+    "expected_entity_id": "python:function:demo.Foo.bar..Local.meth"
+  },
+  {
+    "description": "nested class method chains class names with no locals separator",
+    "plugin_id": "python",
+    "kind": "function",
+    "canonical_qualified_name": "demo.Outer.Inner.method",
+    "expected_entity_id": "python:function:demo.Outer.Inner.method"
+  },
+  {
+    "description": "single-component qualified name",
+    "plugin_id": "python",
+    "kind": "function",
+    "canonical_qualified_name": "hello",
+    "expected_entity_id": "python:function:hello"
+  },
+  {
+    "description": "snake_case name with underscores",
+    "plugin_id": "python",
+    "kind": "function",
+    "canonical_qualified_name": "demo.snake_case_func",
+    "expected_entity_id": "python:function:demo.snake_case_func"
+  },
+  {
+    "description": "name with embedded digits",
+    "plugin_id": "python",
+    "kind": "function",
+    "canonical_qualified_name": "module.name_with_digits_123",
+    "expected_entity_id": "python:function:module.name_with_digits_123"
+  },
+  {
+    "description": "deeply dotted package path",
+    "plugin_id": "python",
+    "kind": "function",
+    "canonical_qualified_name": "pkg.sub.mod.deep_func",
+    "expected_entity_id": "python:function:pkg.sub.mod.deep_func"
+  },
+  {
+    "description": "module entity (future Python plugin kind)",
+    "plugin_id": "python",
+    "kind": "module",
+    "canonical_qualified_name": "pkg.submodule",
+    "expected_entity_id": "python:module:pkg.submodule"
+  },
+  {
+    "description": "core file entity: top-level file",
+    "plugin_id": "core",
+    "kind": "file",
+    "canonical_qualified_name": "demo.py",
+    "expected_entity_id": "core:file:demo.py"
+  },
+  {
+    "description": "core file entity: nested path",
+    "plugin_id": "core",
+    "kind": "file",
+    "canonical_qualified_name": "src/pkg/mod.py",
+    "expected_entity_id": "core:file:src/pkg/mod.py"
+  },
+  {
+    "description": "core file entity: deeply nested path",
+    "plugin_id": "core",
+    "kind": "file",
+    "canonical_qualified_name": "deeply/nested/path/file.py",
+    "expected_entity_id": "core:file:deeply/nested/path/file.py"
+  },
+  {
+    "description": "core file entity: __init__.py",
+    "plugin_id": "core",
+    "kind": "file",
+    "canonical_qualified_name": "pkg/__init__.py",
+    "expected_entity_id": "core:file:pkg/__init__.py"
+  },
+  {
+    "description": "core subsystem entity: content-addressed hash",
+    "plugin_id": "core",
+    "kind": "subsystem",
+    "canonical_qualified_name": "a1b2c3d4",
+    "expected_entity_id": "core:subsystem:a1b2c3d4"
+  },
+  {
+    "description": "core subsystem entity: another hash",
+    "plugin_id": "core",
+    "kind": "subsystem",
+    "canonical_qualified_name": "deadbeef",
+    "expected_entity_id": "core:subsystem:deadbeef"
+  },
+  {
+    "description": "hypothetical go plugin (function entity)",
+    "plugin_id": "go",
+    "kind": "function",
+    "canonical_qualified_name": "mypackage.MyFunc",
+    "expected_entity_id": "go:function:mypackage.MyFunc"
+  },
+  {
+    "description": "hypothetical rust plugin (qualified via dots, not ::)",
+    "plugin_id": "rust",
+    "kind": "function",
+    "canonical_qualified_name": "std.collections.HashMap.new",
+    "expected_entity_id": "rust:function:std.collections.HashMap.new"
+  }
+]
diff --git a/plugins/python/README.md b/plugins/python/README.md
new file mode 100644
index 00000000..69b9b53a
--- /dev/null
+++ b/plugins/python/README.md
@@ -0,0 +1,46 @@
+# clarion-plugin-python
+
+The Python language plugin for [Clarion](../../README.md). Extracts Python
+entities from source files and serves them to the Clarion core over the
+JSON-RPC protocol defined in [WP2 L4](../../docs/implementation/sprint-1/wp2-plugin-host.md#l4--json-rpc-method-set--content-length-framing).
+
+**Status**: Sprint 1 walking-skeleton baseline. Functions only (module-level
+and class methods). Classes, decorators, imports, and call graphs are
+WP3-feature-complete scope.
+
+## Install (development)
+
+```bash
+python -m venv .venv
+source .venv/bin/activate
+pip install -e '.[dev]'
+```
+
+This places `clarion-plugin-python` on your `$PATH` and installs the
+dev-time toolchain (`ruff`, `mypy`, `pytest`, `pytest-cov`, `pre-commit`).
+
+## ADR-023 tooling gates
+
+Every commit must pass all four:
+
+```bash
+ruff check plugins/python
+ruff format --check plugins/python
+mypy --strict plugins/python
+pytest plugins/python
+```
+
+CI runs the same four gates in the `python-plugin` job.
+
+## Design references
+
+- [WP3 plan](../../docs/implementation/sprint-1/wp3-python-plugin.md) — task
+  ledger, lock-ins (L7 qualname, L8 Wardline probe), UQ resolutions.
+- [ADR-003](../../docs/clarion/adr/ADR-003-entity-id-format.md) — 3-segment
+  `EntityId` format this plugin produces.
+- [ADR-018](../../docs/clarion/adr/ADR-018-identity-reconciliation.md) —
+  cross-product identity join with Wardline.
+- [ADR-022](../../docs/clarion/adr/ADR-022-core-plugin-ontology.md) —
+  manifest schema and ontology-boundary enforcement.
+- [ADR-023](../../docs/clarion/adr/ADR-023-tooling-baseline.md) — the four
+  Python gates and the `pre-commit` setup.
diff --git a/plugins/python/plugin.toml b/plugins/python/plugin.toml
new file mode 100644
index 00000000..8473996c
--- /dev/null
+++ b/plugins/python/plugin.toml
@@ -0,0 +1,45 @@
+[plugin]
+name = "clarion-plugin-python"
+plugin_id = "python"
+version = "0.1.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.
+executable = "clarion-plugin-python"
+language = "python"
+extensions = ["py"]
+
+[capabilities.runtime]
+# Plugin's declared RSS envelope (MiB). Effective prlimit is
+# min(this, core default 2 GiB). 512 MiB is comfortable for CPython
+# plus imports plus the Sprint-1 extractor working set.
+expected_max_rss_mb = 512
+# Triggers CLA-INFRA-PLUGIN-ENTITY-OVERRUN-WARNING well before the 500k
+# core cap (warning emission itself is deferred to Tier B — Sprint 1
+# only lands the declaration).
+expected_entities_per_file = 5000
+# L8 integration point — the plugin probes wardline at initialize and
+# reports the outcome in the handshake's capabilities field.
+wardline_aware = true
+# v0.1 rejects `true` at initialize with CLA-INFRA-MANIFEST-UNSUPPORTED-CAPABILITY.
+reads_outside_project_root = false
+
+[ontology]
+# Sprint 1 narrow scope: functions only (WP3 §1). Classes, modules,
+# decorators are WP3-feature-complete kinds.
+entity_kinds = ["function"]
+edge_kinds = []
+# Per ADR-022: uppercase `CLA-{PLUGIN_ID_UPPER}-`. Reserved at parse
+# against the CLA-INFRA-* and CLA-FACT-* namespaces.
+rule_id_prefix = "CLA-PY-"
+# Feeds ADR-007 cache keying; bump when the entity/edge/rule set shifts.
+ontology_version = "0.1.0"
+
+[integrations.wardline]
+# Verified present in Wardline source (src/wardline/core/registry.py:55,
+# src/wardline/__init__.py:3) at sprint close 2026-04-28; current
+# Wardline version is 1.0.0. Pin range admits 1.x; 2.0.0 is an exclusive
+# upper bound so a future major version triggers an explicit re-pin
+# rather than silent drift.
+min_version = "1.0.0"
+max_version = "2.0.0"
diff --git a/plugins/python/pyproject.toml b/plugins/python/pyproject.toml
new file mode 100644
index 00000000..af14c85f
--- /dev/null
+++ b/plugins/python/pyproject.toml
@@ -0,0 +1,91 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "clarion-plugin-python"
+version = "0.1.0"
+description = "Clarion Python language plugin — walking-skeleton v0.1 baseline"
+readme = "README.md"
+requires-python = ">=3.11"
+authors = [{ name = "John Morrissey", email = "qacona@gmail.com" }]
+classifiers = [
+    "Development Status :: 3 - Alpha",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+]
+dependencies = [
+    "packaging>=24",
+]
+
+[project.optional-dependencies]
+dev = [
+    "pytest>=8.0",
+    "pytest-cov>=5.0",
+    "ruff>=0.6",
+    "mypy>=1.11",
+    "pre-commit>=3.8",
+]
+
+[project.scripts]
+clarion-plugin-python = "clarion_plugin_python.__main__:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/clarion_plugin_python"]
+
+[tool.hatch.build.targets.wheel.shared-data]
+# Route plugin.toml into /share/clarion/plugins// so
+# WP2's L9 install-prefix fallback finds it. The  is produced by
+# `discovery.rs::strip_prefix("clarion-plugin-")` on the binary name, so for
+# clarion-plugin-python the suffix is "python" — the target directory must
+# match that basename exactly.
+"plugin.toml" = "share/clarion/plugins/python/plugin.toml"
+
+[tool.ruff]
+target-version = "py311"
+line-length = 100
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = ["ALL"]
+ignore = [
+    "D",      # pydocstyle relaxed per ADR-023
+    "COM812", # conflicts with ruff format
+    "ISC001", # conflicts with ruff format
+    "CPY",    # copyright headers are not our convention
+    "ANN401", # Any is legitimate for JSON-RPC payload fields (shape is open)
+    "TRY003", # short composed exception messages are fine
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = [
+    "S101",    # assert is the whole point of tests
+    "PLR2004", # magic numbers are fine in test expectations
+    "ANN",     # type annotations optional in tests
+    "INP001",  # tests/ is a namespace package
+    "S108",    # hard-coded /tmp paths are test fixtures, not real I/O
+    "E501",    # long assert messages in test failures are fine
+]
+
+[tool.ruff.lint.mccabe]
+# Dispatch loops naturally exceed the default 10; 15 matches our Rust clippy.toml.
+max-complexity = 15
+
+[tool.ruff.lint.pylint]
+max-returns = 10
+max-branches = 15
+
+[tool.ruff.format]
+# defaults: double quotes, space indent, trailing commas where appropriate
+
+[tool.mypy]
+python_version = "3.11"
+strict = true
+warn_unused_configs = true
+files = ["src", "tests"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "--strict-markers --cov=clarion_plugin_python --cov-report=term-missing"
+pythonpath = ["src"]
diff --git a/plugins/python/src/clarion_plugin_python/__init__.py b/plugins/python/src/clarion_plugin_python/__init__.py
new file mode 100644
index 00000000..c60bf654
--- /dev/null
+++ b/plugins/python/src/clarion_plugin_python/__init__.py
@@ -0,0 +1,3 @@
+"""clarion-plugin-python — Python language plugin for Clarion (Sprint 1 walking skeleton)."""
+
+__version__ = "0.1.0"
diff --git a/plugins/python/src/clarion_plugin_python/__main__.py b/plugins/python/src/clarion_plugin_python/__main__.py
new file mode 100644
index 00000000..ab83af17
--- /dev/null
+++ b/plugins/python/src/clarion_plugin_python/__main__.py
@@ -0,0 +1,15 @@
+"""Entry point for the ``clarion-plugin-python`` executable.
+
+Installs stdout discipline (``stdout_guard``) and hands control to the
+JSON-RPC server loop. ``sys.exit`` threads the server's exit code out to
+the host process.
+"""
+
+from __future__ import annotations
+
+import sys
+
+from clarion_plugin_python.server import main
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/plugins/python/src/clarion_plugin_python/entity_id.py b/plugins/python/src/clarion_plugin_python/entity_id.py
new file mode 100644
index 00000000..763788b6
--- /dev/null
+++ b/plugins/python/src/clarion_plugin_python/entity_id.py
@@ -0,0 +1,75 @@
+"""L2 3-segment EntityId assembler matching WP1's Rust ``entity_id()`` byte-for-byte.
+
+Per ADR-003 + ADR-022, every Clarion entity has a 3-segment ID of the
+form ``{plugin_id}:{kind}:{canonical_qualified_name}``.
+
+Validation (mirrors ``crates/clarion-core/src/entity_id.rs``):
+
+- ``plugin_id`` and ``kind`` must match the identifier grammar
+  ``[a-z][a-z0-9_]*`` (ADR-022).
+- No segment may contain a literal ``:`` (reserved separator).
+- No segment may be empty.
+
+The shared fixture ``fixtures/entity_id.json`` (Task 5) drives the
+cross-language parity check: both the Rust assembler and this Python
+assembler consume the same fixture rows and must produce identical
+strings byte-for-byte.
+"""
+
+from __future__ import annotations
+
+import re
+
+_GRAMMAR = re.compile(r"^[a-z][a-z0-9_]*$")
+
+
+class EntityIdError(ValueError):
+    """Base class for all ``entity_id()`` validation failures."""
+
+
+class EmptySegmentError(EntityIdError):
+    """A segment (``plugin_id``, ``kind``, or ``canonical_qualified_name``) was empty."""
+
+    def __init__(self, field: str) -> None:
+        super().__init__(f"segment {field} empty")
+        self.field = field
+
+
+class GrammarViolationError(EntityIdError):
+    """A segment did not match the ADR-022 grammar ``[a-z][a-z0-9_]*``."""
+
+    def __init__(self, field: str, value: str) -> None:
+        super().__init__(f"segment {field} violates ADR-022 grammar [a-z][a-z0-9_]*: {value!r}")
+        self.field = field
+        self.value = value
+
+
+class SegmentContainsColonError(EntityIdError):
+    """A segment contained the reserved ``:`` separator (UQ-WP1-07)."""
+
+    def __init__(self, field: str, value: str) -> None:
+        super().__init__(f"segment {field} contains reserved ':' separator: {value!r}")
+        self.field = field
+        self.value = value
+
+
+def _validate_grammar(field: str, value: str) -> None:
+    """Mirror ``validate_grammar`` in the Rust side — empty, colon, then regex."""
+    if not value:
+        raise EmptySegmentError(field)
+    if ":" in value:
+        raise SegmentContainsColonError(field, value)
+    if not _GRAMMAR.fullmatch(value):
+        raise GrammarViolationError(field, value)
+
+
+def entity_id(plugin_id: str, kind: str, canonical_qualified_name: str) -> str:
+    """Assemble the 3-segment EntityId string with full validation."""
+    _validate_grammar("plugin_id", plugin_id)
+    _validate_grammar("kind", kind)
+    qn_field = "canonical_qualified_name"
+    if not canonical_qualified_name:
+        raise EmptySegmentError(qn_field)
+    if ":" in canonical_qualified_name:
+        raise SegmentContainsColonError(qn_field, canonical_qualified_name)
+    return f"{plugin_id}:{kind}:{canonical_qualified_name}"
diff --git a/plugins/python/src/clarion_plugin_python/extractor.py b/plugins/python/src/clarion_plugin_python/extractor.py
new file mode 100644
index 00000000..03e143ad
--- /dev/null
+++ b/plugins/python/src/clarion_plugin_python/extractor.py
@@ -0,0 +1,152 @@
+"""AST → function-entity extractor for the Python plugin (WP3 Task 4).
+
+Walks a parsed Python file and emits one entity per ``FunctionDef`` /
+``AsyncFunctionDef`` (Sprint 1 scope). Class, decorator, module, and
+import/call edge emission is WP3-feature-complete scope and deliberately
+out of band here.
+
+Entity shape matches the Rust host's ``RawEntity`` + ``RawSource``
+contract (``crates/clarion-core/src/plugin/host.rs:132-154``)::
+
+    {
+        "id": "python:function:...",
+        "kind": "function",
+        "qualified_name": "pkg.module.func",
+        "source": {
+            "file_path": "pkg/module.py",
+            "source_range": {
+                "start_line": 1, "start_col": 0,
+                "end_line": 3, "end_col": 4,
+            },
+        },
+    }
+
+``source.file_path`` lands in the host's path jail (canonicalised +
+checked against ``project_root``); any other source-side fields flow
+through ``RawSource.extra`` (serde flatten) and are bounded by
+``MAX_ENTITY_EXTRA_BYTES`` (64 KiB). ``qualified_name`` is the dotted
+module prefix joined to Python's own ``__qualname__`` (reconstructed
+per L7). The file_path passed on the wire may be absolute (what the
+host sent) while the prefix used for qualified-name dotting can be the
+relativised form — the two are decoupled via ``extract``'s
+``module_prefix_path`` kwarg.
+
+Behaviour:
+
+- Empty or comment-only file → empty list (UQ-WP3-11).
+- ``SyntaxError`` during ``ast.parse`` → empty list + one stderr log line
+  (UQ-WP3-02). The run continues; WP4-era findings can later attach a
+  ``CLA-PY-SYNTAX-ERROR`` annotation.
+- Paths starting with ``src/`` have the prefix stripped (UQ-WP3-05).
+- ``pkg/__init__.py`` files yield qualified_names rooted at ``pkg``
+  (not ``pkg.__init__``) — UQ-WP3-06.
+"""
+
+from __future__ import annotations
+
+import ast
+import sys
+from pathlib import PurePosixPath
+from typing import Any
+
+from clarion_plugin_python.entity_id import entity_id
+from clarion_plugin_python.qualname import reconstruct_qualname
+
+_PLUGIN_ID = "python"
+_KIND = "function"
+
+
+def module_dotted_name(module_path: str) -> str:
+    """Derive the dotted module prefix from a root-relative source path.
+
+    Rules:
+    - Leading ``src/`` is stripped (UQ-WP3-05).
+    - The ``.py`` suffix is dropped.
+    - ``__init__`` filenames collapse to their containing package
+      (UQ-WP3-06: ``pkg/__init__.py`` → ``pkg``).
+    - Path separators become ``.``.
+
+    ``module_path`` itself remains unchanged; it's stored on the entity
+    as a property so WP4 can still find the file on disk.
+    """
+    parts = list(PurePosixPath(module_path).parts)
+    if parts and parts[0] == "src":
+        parts = parts[1:]
+    if parts:
+        last = parts[-1]
+        if last.endswith(".py"):
+            stem = last[:-3]
+            if stem == "__init__":
+                parts = parts[:-1]
+            else:
+                parts[-1] = stem
+    return ".".join(parts)
+
+
+def extract(
+    source: str,
+    file_path: str,
+    *,
+    module_prefix_path: str | None = None,
+) -> list[dict[str, Any]]:
+    """Return a list of function entities extracted from ``source``.
+
+    ``file_path`` lands in each entity's ``source.file_path`` verbatim.
+    ``module_prefix_path`` (default: same as ``file_path``) is the path
+    whose dotted form prefixes every entity's ``qualified_name`` — callers
+    can supply a project-relative path here while keeping ``file_path``
+    absolute so the host's path jail validates the original path.
+    """
+    try:
+        tree = ast.parse(source)
+    except SyntaxError as exc:
+        sys.stderr.write(
+            f"clarion-plugin-python: skipping {file_path}: syntax error at "
+            f"line {exc.lineno}: {exc.msg}\n",
+        )
+        return []
+
+    prefix_source = module_prefix_path if module_prefix_path is not None else file_path
+    dotted_module = module_dotted_name(prefix_source)
+    entities: list[dict[str, Any]] = []
+    _walk(tree, [tree], dotted_module, file_path, entities)
+    return entities
+
+
+def _walk(
+    node: ast.AST,
+    parents: list[ast.AST],
+    dotted_module: str,
+    file_path: str,
+    out: list[dict[str, Any]],
+) -> None:
+    for child in ast.iter_child_nodes(node):
+        if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
+            out.append(_build_entity(child, parents, dotted_module, file_path))
+        _walk(child, [*parents, child], dotted_module, file_path, out)
+
+
+def _build_entity(
+    node: ast.FunctionDef | ast.AsyncFunctionDef,
+    parents: list[ast.AST],
+    dotted_module: str,
+    file_path: str,
+) -> dict[str, Any]:
+    python_qualname = reconstruct_qualname(node, parents)
+    qualified_name = f"{dotted_module}.{python_qualname}" if dotted_module else python_qualname
+    end_line = node.end_lineno if node.end_lineno is not None else node.lineno
+    end_col = node.end_col_offset if node.end_col_offset is not None else node.col_offset
+    return {
+        "id": entity_id(_PLUGIN_ID, _KIND, qualified_name),
+        "kind": _KIND,
+        "qualified_name": qualified_name,
+        "source": {
+            "file_path": file_path,
+            "source_range": {
+                "start_line": node.lineno,
+                "start_col": node.col_offset,
+                "end_line": end_line,
+                "end_col": end_col,
+            },
+        },
+    }
diff --git a/plugins/python/src/clarion_plugin_python/py.typed b/plugins/python/src/clarion_plugin_python/py.typed
new file mode 100644
index 00000000..e69de29b
diff --git a/plugins/python/src/clarion_plugin_python/qualname.py b/plugins/python/src/clarion_plugin_python/qualname.py
new file mode 100644
index 00000000..3b7ce290
--- /dev/null
+++ b/plugins/python/src/clarion_plugin_python/qualname.py
@@ -0,0 +1,46 @@
+"""L7 qualname reconstruction matching Python's ``__qualname__`` semantics.
+
+Python's ``__qualname__`` is only bound at runtime, after the function or
+class definition has been executed; Clarion's static analyser has to
+reconstruct the same string from the AST and the chain of parent scopes.
+
+Rules (CPython language reference, "``__qualname__``"):
+
+- Module-level function/class: qualname == name.
+- Class-nested (class body contains a function/class): qualname prepends
+  the enclosing class names joined by ``.`` with no separator marker.
+- Function-nested (function body contains a function/class): qualname
+  prepends ``parent..`` — the ```` marker distinguishes a
+  closure from a method.
+
+The L7 lock-in (``wp3-python-plugin.md §L7``) is that Clarion's Python
+plugin and Wardline's annotations must produce the same string here;
+divergence breaks the cross-product identity join (ADR-018).
+
+Sprint 1 covers ``FunctionDef`` and ``AsyncFunctionDef`` as emitted
+entities; ``ClassDef`` is recognised as a parent scope only (class
+entities are WP3-feature-complete scope).
+"""
+
+from __future__ import annotations
+
+import ast
+
+Scope = ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef
+
+
+def reconstruct_qualname(node: Scope, parents: list[ast.AST]) -> str:
+    """Return Python's ``__qualname__`` for ``node`` given its AST parent chain.
+
+    ``parents`` is ordered from outermost (typically the ``ast.Module``) to
+    the immediate parent. Non-scope ancestors (e.g. ``Module``,
+    ``ast.If`` bodies, ``ast.With`` bodies) are skipped — they do not
+    contribute to ``__qualname__``.
+    """
+    name = node.name
+    for ancestor in reversed(parents):
+        if isinstance(ancestor, (ast.FunctionDef, ast.AsyncFunctionDef)):
+            name = f"{ancestor.name}..{name}"
+        elif isinstance(ancestor, ast.ClassDef):
+            name = f"{ancestor.name}.{name}"
+    return name
diff --git a/plugins/python/src/clarion_plugin_python/server.py b/plugins/python/src/clarion_plugin_python/server.py
new file mode 100644
index 00000000..88701fe7
--- /dev/null
+++ b/plugins/python/src/clarion_plugin_python/server.py
@@ -0,0 +1,253 @@
+"""WP2 L4 JSON-RPC server speaking Content-Length framing.
+
+Implements the five L4 methods — ``initialize``, ``initialized``,
+``analyze_file``, ``shutdown``, ``exit`` — exactly matching the Rust host's
+typed request/response contracts in ``crates/clarion-core/src/plugin/protocol.rs``.
+
+Response shapes (required by the Rust host's typed deserialise path):
+
+- ``initialize`` → ``{name, version, ontology_version, capabilities}``
+  (``InitializeResult``; WP2 scrub commit ``1ac32b1`` validates
+  ``ontology_version`` is non-empty).
+- ``analyze_file`` → ``{entities: [...]}`` (``AnalyzeFileResult``).
+- ``shutdown`` → ``{}`` (empty ``ShutdownResult`` struct — *not* ``null``).
+- ``initialized`` / ``exit`` — notifications, no response.
+
+Task 2 ships the dispatch skeleton with ``analyze_file`` returning an empty
+entity list. Task 6 wires the Wardline probe result into ``capabilities``;
+Task 7 replaces ``handle_analyze_file`` with the extractor.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import IO, Any
+
+from clarion_plugin_python import __version__
+from clarion_plugin_python.extractor import extract
+from clarion_plugin_python.stdout_guard import install_stdio
+from clarion_plugin_python.wardline_probe import probe as wardline_probe
+
+ONTOLOGY_VERSION = "0.1.0"
+
+# Sprint 1 defaults for the Wardline version pin (WP3 L8 + plugin.toml
+# `[integrations.wardline]`). Kept as module constants so Task 7's
+# manifest values match by inspection; a future sprint can flow these
+# through from the parsed manifest on demand.
+WARDLINE_MIN_VERSION = "1.0.0"
+WARDLINE_MAX_VERSION = "2.0.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
+# for. Oversize outbound payloads trip this before reaching the wire.
+MAX_CONTENT_LENGTH = 8 * 1024 * 1024
+
+# JSON-RPC 2.0 error codes (§5.1) plus LSP-style server extensions.
+_ERR_INVALID_REQUEST = -32600
+_ERR_METHOD_NOT_FOUND = -32601
+_ERR_INTERNAL = -32603
+_ERR_NOT_INITIALIZED = -32002
+
+
+class ProtocolError(RuntimeError):
+    """Unrecoverable framing or envelope error; the server loop exits."""
+
+
+@dataclass
+class ServerState:
+    """Handshake + shutdown + project-root state across the dispatch loop."""
+
+    initialized: bool = False
+    shutdown_requested: bool = False
+    project_root: Path | None = field(default=None)
+
+
+def read_frame(stream: IO[bytes]) -> dict[str, Any] | None:
+    """Read one Content-Length-framed JSON object. Returns ``None`` on EOF."""
+    headers: dict[str, str] = {}
+    while True:
+        line = stream.readline()
+        if not line:
+            return None
+        if line in (b"\r\n", b"\n"):
+            break
+        decoded = line.decode("ascii").rstrip("\r\n")
+        if ":" not in decoded:
+            msg = f"malformed header line: {decoded!r}"
+            raise ProtocolError(msg)
+        name, value = decoded.split(":", 1)
+        headers[name.strip().lower()] = value.strip()
+
+    raw_length = headers.get("content-length")
+    if raw_length is None:
+        msg = "missing Content-Length header"
+        raise ProtocolError(msg)
+    try:
+        length = int(raw_length)
+    except ValueError as exc:
+        msg = f"Content-Length not an integer: {raw_length!r}"
+        raise ProtocolError(msg) from exc
+    if length < 0 or length > MAX_CONTENT_LENGTH:
+        msg = f"Content-Length out of range: {length}"
+        raise ProtocolError(msg)
+
+    body = stream.read(length)
+    if len(body) != length:
+        msg = f"short read: expected {length} bytes, got {len(body)}"
+        raise ProtocolError(msg)
+
+    try:
+        parsed = json.loads(body)
+    except json.JSONDecodeError as exc:
+        msg = f"invalid JSON body: {exc}"
+        raise ProtocolError(msg) from exc
+
+    if not isinstance(parsed, dict):
+        msg = f"expected JSON object at frame root, got {type(parsed).__name__}"
+        raise ProtocolError(msg)
+    return parsed
+
+
+def write_frame(stream: IO[bytes], payload: dict[str, Any]) -> None:
+    """Serialise ``payload`` as one Content-Length-framed JSON frame."""
+    body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
+    if len(body) > MAX_CONTENT_LENGTH:
+        msg = f"outbound frame exceeds MAX_CONTENT_LENGTH ({len(body)} > {MAX_CONTENT_LENGTH})"
+        raise ProtocolError(msg)
+    header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
+    stream.write(header)
+    stream.write(body)
+    stream.flush()
+
+
+def _success(request_id: Any, result: Any) -> dict[str, Any]:
+    return {"jsonrpc": "2.0", "id": request_id, "result": result}
+
+
+def _error(request_id: Any, code: int, message: str) -> dict[str, Any]:
+    return {
+        "jsonrpc": "2.0",
+        "id": request_id,
+        "error": {"code": code, "message": message},
+    }
+
+
+def handle_initialize(params: dict[str, Any], state: ServerState) -> dict[str, Any]:
+    """Return the plugin's identity + capabilities; capture ``project_root``."""
+    root_raw = params.get("project_root")
+    if isinstance(root_raw, str) and root_raw:
+        state.project_root = Path(root_raw).resolve()
+    return {
+        "name": "clarion-plugin-python",
+        "version": __version__,
+        "ontology_version": ONTOLOGY_VERSION,
+        "capabilities": {
+            "wardline": wardline_probe(WARDLINE_MIN_VERSION, WARDLINE_MAX_VERSION),
+        },
+    }
+
+
+def _resolve_module_path(file_path_raw: str, state: ServerState) -> str:
+    """Compute the entity ``module_path`` relative to ``project_root``.
+
+    The host sends absolute paths (see ``crates/clarion-cli/src/analyze.rs``
+    — ``project_root`` is canonicalised and file entries are built by
+    ``entry.path()`` joins). To produce the expected L7 qualified names
+    (``pkg.module.func`` rather than ``tmp.xyz.demo.func``), the plugin
+    relativises each incoming path against the ``project_root`` captured
+    at ``initialize``.
+    """
+    path = Path(file_path_raw)
+    if state.project_root is not None and path.is_absolute():
+        try:
+            return str(path.resolve().relative_to(state.project_root))
+        except ValueError:
+            # Outside project_root — host's jail should have caught this.
+            # Fall back to the raw path so the host's logs show the drift.
+            return file_path_raw
+    return file_path_raw
+
+
+def handle_analyze_file(params: dict[str, Any], state: ServerState) -> dict[str, Any]:
+    """Read the requested file, extract entities, return AnalyzeFileResult shape."""
+    file_path_raw = params.get("file_path")
+    if not isinstance(file_path_raw, str):
+        return {"entities": []}
+    path = Path(file_path_raw)
+    try:
+        source = path.read_text(encoding="utf-8")
+    except (OSError, UnicodeDecodeError) as exc:
+        sys.stderr.write(f"clarion-plugin-python: cannot read {file_path_raw}: {exc}\n")
+        return {"entities": []}
+    # Emit source.file_path exactly as received so the host's jail check
+    # (which canonicalises against project_root) sees the original path.
+    # Derive qualified-name dotting from the project-relative form.
+    module_prefix = _resolve_module_path(file_path_raw, state)
+    return {
+        "entities": extract(source, file_path_raw, module_prefix_path=module_prefix),
+    }
+
+
+Handler = Callable[[dict[str, Any], ServerState], dict[str, Any]]
+
+_HANDLERS: dict[str, Handler] = {
+    "initialize": handle_initialize,
+    "analyze_file": handle_analyze_file,
+}
+
+
+def dispatch(frame: dict[str, Any], state: ServerState) -> dict[str, Any] | None:
+    """Process one frame; return the response envelope to send, or ``None``."""
+    method = frame.get("method")
+    params_raw = frame.get("params")
+    params: dict[str, Any] = params_raw if isinstance(params_raw, dict) else {}
+    request_id = frame.get("id")
+
+    if method == "initialized":
+        state.initialized = True
+        return None
+    if method == "exit":
+        return None
+    if method == "shutdown":
+        state.shutdown_requested = True
+        return _success(request_id, {})
+    if not isinstance(method, str):
+        return _error(request_id, _ERR_INVALID_REQUEST, f"invalid method: {method!r}")
+    if method == "analyze_file" and not state.initialized:
+        return _error(request_id, _ERR_NOT_INITIALIZED, "analyze_file before initialized")
+    handler = _HANDLERS.get(method)
+    if handler is None:
+        return _error(request_id, _ERR_METHOD_NOT_FOUND, f"method not found: {method}")
+    try:
+        result = handler(params, state)
+    except Exception as exc:  # noqa: BLE001 - dispatch boundary: any handler bug becomes a response
+        return _error(request_id, _ERR_INTERNAL, f"handler failed: {exc}")
+    return _success(request_id, result)
+
+
+def serve(stdin: IO[bytes], stdout: IO[bytes]) -> int:
+    """Run the dispatch loop until EOF or ``exit`` notification."""
+    state = ServerState()
+    while True:
+        frame = read_frame(stdin)
+        if frame is None:
+            return 0
+        method = frame.get("method")
+        response = dispatch(frame, state)
+        if response is not None:
+            write_frame(stdout, response)
+        if method == "exit":
+            return 0 if state.shutdown_requested else 1
+
+
+def main() -> int:
+    """Install stdout discipline, run the server loop, translate errors to exit codes."""
+    stdin, stdout = install_stdio()
+    try:
+        return serve(stdin, stdout)
+    except ProtocolError:
+        return 1
diff --git a/plugins/python/src/clarion_plugin_python/stdout_guard.py b/plugins/python/src/clarion_plugin_python/stdout_guard.py
new file mode 100644
index 00000000..1514de6f
--- /dev/null
+++ b/plugins/python/src/clarion_plugin_python/stdout_guard.py
@@ -0,0 +1,62 @@
+"""Stdout discipline for JSON-RPC plugins (WP2 UQ-WP2-08 plugin-side resolution).
+
+The Clarion plugin protocol reserves ``stdout`` for Content-Length-framed
+JSON-RPC frames. A stray ``print()`` or library-emitted message on stdout
+would corrupt the framing parser on the host side and trip either the
+Content-Length ceiling or the JSON decoder.
+
+``install_stdio()`` captures the real ``stdin``/``stdout`` byte streams,
+replaces ``sys.stdout`` with a guard that raises ``StdoutGuardError`` on
+any write, and returns the captured ``(stdin, stdout)`` pair for the
+server to use. Callers must invoke this exactly once, before reading or
+writing any framed data.
+"""
+
+from __future__ import annotations
+
+import sys
+from typing import IO
+
+
+class StdoutGuardError(RuntimeError):
+    """Raised when Python code writes to the guarded stdout after ``install_stdio``."""
+
+
+class _GuardedTextStdout:
+    """``sys.stdout`` replacement that refuses every write.
+
+    Only implements the attributes and methods CPython routinely looks up
+    on ``sys.stdout`` — enough to surface the guard error clearly instead of
+    failing with ``AttributeError`` first.
+    """
+
+    encoding = "utf-8"
+    errors = "strict"
+
+    def write(self, _data: str) -> int:
+        msg = (
+            "plugin stdout is reserved for JSON-RPC framing; "
+            "write to sys.stderr for diagnostics or raise an exception"
+        )
+        raise StdoutGuardError(msg)
+
+    def writelines(self, _lines: object) -> None:
+        self.write("")
+
+    def flush(self) -> None:
+        return
+
+    def isatty(self) -> bool:
+        return False
+
+    def fileno(self) -> int:
+        msg = "guarded stdout has no fileno"
+        raise StdoutGuardError(msg)
+
+
+def install_stdio() -> tuple[IO[bytes], IO[bytes]]:
+    """Reserve stdout for JSON-RPC; return the real ``(stdin, stdout)`` byte streams."""
+    real_stdin = sys.stdin.buffer
+    real_stdout = sys.stdout.buffer
+    sys.stdout = _GuardedTextStdout()
+    return real_stdin, real_stdout
diff --git a/plugins/python/src/clarion_plugin_python/wardline_probe.py b/plugins/python/src/clarion_plugin_python/wardline_probe.py
new file mode 100644
index 00000000..ea4fa5ba
--- /dev/null
+++ b/plugins/python/src/clarion_plugin_python/wardline_probe.py
@@ -0,0 +1,56 @@
+"""L8 Wardline REGISTRY import + version-pin probe (WP3 Task 6).
+
+At ``initialize``, the Python plugin runs this probe to report its
+Wardline integration state in the handshake's ``capabilities.wardline``
+field. The probe is deliberately fail-soft: Wardline missing or
+out-of-range is not an error — the plugin continues to extract entities,
+just without the REGISTRY cross-check.
+
+Three states, matching the WP3 doc §L8:
+
+- ``{"status": "absent"}`` — Wardline package not installed (or
+  ``wardline.core.registry`` not importable; or ``wardline.__version__``
+  is missing / not a string).
+- ``{"status": "enabled", "version": "X.Y.Z"}`` — installed and
+  ``wardline.__version__`` is in the half-open range
+  ``[min_version, max_version)``.
+- ``{"status": "version_out_of_range", "version": "X.Y.Z"}`` —
+  installed but version outside the declared range.
+
+Sprint 1 does not consume REGISTRY; the probe only proves the import
+works + the version-pin handshake is wired end-to-end. Full REGISTRY
+joining is WP3-feature-complete scope (ADR-018).
+"""
+
+from __future__ import annotations
+
+import importlib
+from typing import Any
+
+from packaging.version import InvalidVersion, Version
+
+_ABSENT: dict[str, Any] = {"status": "absent"}
+
+
+def probe(min_version: str, max_version: str) -> dict[str, Any]:
+    """Probe the Wardline package for presence and version compatibility."""
+    try:
+        importlib.import_module("wardline.core.registry")
+        wardline = importlib.import_module("wardline")
+    except ImportError:
+        return _ABSENT
+
+    raw_version = getattr(wardline, "__version__", None)
+    if not isinstance(raw_version, str):
+        return _ABSENT
+
+    try:
+        version = Version(raw_version)
+        low = Version(min_version)
+        high = Version(max_version)
+    except InvalidVersion:
+        return _ABSENT
+
+    if low <= version < high:
+        return {"status": "enabled", "version": raw_version}
+    return {"status": "version_out_of_range", "version": raw_version}
diff --git a/plugins/python/tests/__init__.py b/plugins/python/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/plugins/python/tests/test_entity_id.py b/plugins/python/tests/test_entity_id.py
new file mode 100644
index 00000000..2ea56de1
--- /dev/null
+++ b/plugins/python/tests/test_entity_id.py
@@ -0,0 +1,118 @@
+"""Unit tests for the 3-segment EntityId assembler (WP3 Task 4).
+
+Task 5 replaces the Python-side expected-string literals with rows from
+the shared ``fixtures/entity_id.json`` — which WP1's Rust tests will
+also consume — for the byte-for-byte L2 parity proof.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from clarion_plugin_python.entity_id import (
+    EmptySegmentError,
+    GrammarViolationError,
+    SegmentContainsColonError,
+    entity_id,
+)
+
+# Repo root is four parents up from this test file:
+# plugins/python/tests/test_entity_id.py → ... → /repo
+_REPO_ROOT = Path(__file__).resolve().parents[3]
+_FIXTURE_PATH = _REPO_ROOT / "fixtures" / "entity_id.json"
+
+
+def test_module_level_function_id() -> None:
+    assert entity_id("python", "function", "demo.hello") == "python:function:demo.hello"
+
+
+def test_class_method_id() -> None:
+    assert entity_id("python", "function", "demo.Foo.bar") == "python:function:demo.Foo.bar"
+
+
+def test_nested_function_id_carries_locals_marker() -> None:
+    assert (
+        entity_id("python", "function", "demo.outer..inner")
+        == "python:function:demo.outer..inner"
+    )
+
+
+def test_core_file_id() -> None:
+    assert entity_id("core", "file", "src/demo.py") == "core:file:src/demo.py"
+
+
+def test_core_subsystem_id() -> None:
+    assert entity_id("core", "subsystem", "a1b2c3d4") == "core:subsystem:a1b2c3d4"
+
+
+def test_rejects_empty_plugin_id() -> None:
+    with pytest.raises(EmptySegmentError) as exc_info:
+        entity_id("", "function", "demo.hello")
+    assert exc_info.value.field == "plugin_id"
+
+
+def test_rejects_empty_kind() -> None:
+    with pytest.raises(EmptySegmentError) as exc_info:
+        entity_id("python", "", "demo.hello")
+    assert exc_info.value.field == "kind"
+
+
+def test_rejects_empty_qualified_name() -> None:
+    with pytest.raises(EmptySegmentError) as exc_info:
+        entity_id("python", "function", "")
+    assert exc_info.value.field == "canonical_qualified_name"
+
+
+def test_rejects_uppercase_plugin_id() -> None:
+    with pytest.raises(GrammarViolationError) as exc_info:
+        entity_id("Python", "function", "demo.hello")
+    assert exc_info.value.field == "plugin_id"
+
+
+def test_rejects_digit_prefixed_kind() -> None:
+    with pytest.raises(GrammarViolationError) as exc_info:
+        entity_id("python", "1function", "demo.hello")
+    assert exc_info.value.field == "kind"
+
+
+def test_rejects_hyphen_in_kind() -> None:
+    with pytest.raises(GrammarViolationError) as exc_info:
+        entity_id("python", "func-tion", "demo.hello")
+    assert exc_info.value.field == "kind"
+
+
+def test_rejects_colon_in_qualified_name() -> None:
+    with pytest.raises(SegmentContainsColonError) as exc_info:
+        entity_id("python", "function", "demo:hello")
+    assert exc_info.value.field == "canonical_qualified_name"
+
+
+def test_rejects_colon_in_plugin_id() -> None:
+    """Colon check fires before grammar check (defence in depth, matches Rust)."""
+    with pytest.raises(SegmentContainsColonError) as exc_info:
+        entity_id("py:thon", "function", "demo.hello")
+    assert exc_info.value.field == "plugin_id"
+
+
+def test_matches_shared_fixture() -> None:
+    """UQ-WP3-08: byte-for-byte L2 parity with the Rust assembler.
+
+    The same ``fixtures/entity_id.json`` is consumed by
+    ``crates/clarion-core/src/entity_id.rs::tests::shared_fixture_byte_for_byte_parity``.
+    If this test or the Rust test disagrees on any row, the ID scheme has
+    drifted between languages — the cross-product identity join (ADR-018)
+    would break silently. CI fails both sides in lockstep.
+    """
+    with _FIXTURE_PATH.open() as fh:
+        rows = json.load(fh)
+    assert len(rows) >= 20, f"fixture must have >=20 rows, got {len(rows)}"
+    for row in rows:
+        actual = entity_id(
+            row["plugin_id"],
+            row["kind"],
+            row["canonical_qualified_name"],
+        )
+        assert actual == row["expected_entity_id"], f"mismatch for row {row!r}"
diff --git a/plugins/python/tests/test_extractor.py b/plugins/python/tests/test_extractor.py
new file mode 100644
index 00000000..8046dcc2
--- /dev/null
+++ b/plugins/python/tests/test_extractor.py
@@ -0,0 +1,115 @@
+"""Unit tests for the AST → function-entity extractor (WP3 Task 4)."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from clarion_plugin_python.extractor import extract, module_dotted_name
+
+if TYPE_CHECKING:
+    import pytest
+
+
+def test_empty_file_yields_zero_entities() -> None:
+    """UQ-WP3-11: an empty .py file has zero functions (host must tolerate)."""
+    assert extract("", "empty.py") == []
+
+
+def test_whitespace_only_file_yields_zero_entities() -> None:
+    assert extract("\n\n# just a comment\n", "empty.py") == []
+
+
+def test_module_level_function() -> None:
+    entities = extract("def hello():\n    pass\n", "demo.py")
+    assert len(entities) == 1
+    entity = entities[0]
+    assert entity["id"] == "python:function:demo.hello"
+    assert entity["kind"] == "function"
+    assert entity["qualified_name"] == "demo.hello"
+    assert entity["source"]["file_path"] == "demo.py"
+    assert entity["source"]["source_range"]["start_line"] == 1
+    assert entity["source"]["source_range"]["start_col"] == 0
+
+
+def test_class_method() -> None:
+    entities = extract("class Foo:\n    def bar(self):\n        pass\n", "demo.py")
+    assert len(entities) == 1
+    assert entities[0]["id"] == "python:function:demo.Foo.bar"
+
+
+def test_nested_function_emits_both_outer_and_inner() -> None:
+    entities = extract("def outer():\n    def inner():\n        pass\n", "demo.py")
+    ids = {e["id"] for e in entities}
+    assert ids == {
+        "python:function:demo.outer",
+        "python:function:demo.outer..inner",
+    }
+
+
+def test_async_function() -> None:
+    entities = extract("async def aloha():\n    pass\n", "demo.py")
+    assert len(entities) == 1
+    assert entities[0]["id"] == "python:function:demo.aloha"
+
+
+def test_nested_class_method() -> None:
+    source = "class Outer:\n    class Inner:\n        def method(self):\n            pass\n"
+    entities = extract(source, "demo.py")
+    assert len(entities) == 1
+    assert entities[0]["id"] == "python:function:demo.Outer.Inner.method"
+
+
+def test_syntax_error_yields_empty_list_and_logs_to_stderr(
+    capsys: pytest.CaptureFixture[str],
+) -> None:
+    """UQ-WP3-02: SyntaxError files are skipped + logged, not fatal."""
+    result = extract("def :", "broken.py")
+    assert result == []
+    captured = capsys.readouterr()
+    assert "broken.py" in captured.err
+
+
+def test_src_prefix_stripped() -> None:
+    """UQ-WP3-05: `src/pkg/module.py` → dotted module `pkg.module`."""
+    entities = extract("def hello():\n    pass\n", "src/pkg/module.py")
+    assert entities[0]["qualified_name"] == "pkg.module.hello"
+
+
+def test_init_py_collapsed_to_package_name() -> None:
+    """UQ-WP3-06: `pkg/__init__.py` → dotted `pkg` (not `pkg.__init__`).
+
+    ``source.file_path`` stays as the literal file path; the dotted module
+    used for qualified_name is the package name only.
+    """
+    entities = extract("def pkg_helper():\n    pass\n", "pkg/__init__.py")
+    assert entities[0]["qualified_name"] == "pkg.pkg_helper"
+    assert entities[0]["source"]["file_path"] == "pkg/__init__.py"
+
+
+def test_module_prefix_path_decouples_file_path_and_dotted_prefix() -> None:
+    """server passes absolute file_path + relativised module_prefix_path."""
+    entities = extract(
+        "def hello():\n    pass\n",
+        "/tmp/proj/demo.py",
+        module_prefix_path="demo.py",
+    )
+    assert entities[0]["source"]["file_path"] == "/tmp/proj/demo.py"
+    assert entities[0]["id"] == "python:function:demo.hello"
+    assert entities[0]["qualified_name"] == "demo.hello"
+
+
+def test_module_dotted_name_helper() -> None:
+    assert module_dotted_name("demo.py") == "demo"
+    assert module_dotted_name("src/demo.py") == "demo"
+    assert module_dotted_name("pkg/__init__.py") == "pkg"
+    assert module_dotted_name("src/pkg/mod.py") == "pkg.mod"
+    assert module_dotted_name("src/pkg/sub/mod.py") == "pkg.sub.mod"
+
+
+def test_source_range_end_fields_populated() -> None:
+    entities = extract("def f():\n    pass\n", "d.py")
+    source_range = entities[0]["source"]["source_range"]
+    assert source_range["start_line"] == 1
+    assert source_range["start_col"] == 0
+    assert source_range["end_line"] == 2
+    assert source_range["end_col"] >= 0
diff --git a/plugins/python/tests/test_package.py b/plugins/python/tests/test_package.py
new file mode 100644
index 00000000..46325a39
--- /dev/null
+++ b/plugins/python/tests/test_package.py
@@ -0,0 +1,9 @@
+"""Package-level smoke test: version string is pinned to the pyproject value."""
+
+from __future__ import annotations
+
+import clarion_plugin_python
+
+
+def test_package_version_matches_pyproject() -> None:
+    assert clarion_plugin_python.__version__ == "0.1.0"
diff --git a/plugins/python/tests/test_qualname.py b/plugins/python/tests/test_qualname.py
new file mode 100644
index 00000000..4f946db2
--- /dev/null
+++ b/plugins/python/tests/test_qualname.py
@@ -0,0 +1,138 @@
+"""Unit tests for L7 qualname reconstruction (WP3 Task 3).
+
+Each test parses a short source snippet, locates the target FunctionDef/
+AsyncFunctionDef, and asserts ``reconstruct_qualname`` returns the same
+string that Python itself would put on ``node.__qualname__`` at runtime.
+
+The golden strings are taken from the Python language reference
+(``__qualname__`` semantics) — if one of these drifts, Wardline's
+cross-product join (ADR-018) breaks.
+"""
+
+from __future__ import annotations
+
+import ast
+
+from clarion_plugin_python.qualname import reconstruct_qualname
+
+
+def _parse(source: str) -> ast.Module:
+    return ast.parse(source)
+
+
+def test_module_level_function() -> None:
+    tree = _parse("def hello():\n    pass\n")
+    func = tree.body[0]
+    assert isinstance(func, ast.FunctionDef)
+    assert reconstruct_qualname(func, [tree]) == "hello"
+
+
+def test_module_level_async_function() -> None:
+    tree = _parse("async def aloha():\n    pass\n")
+    func = tree.body[0]
+    assert isinstance(func, ast.AsyncFunctionDef)
+    assert reconstruct_qualname(func, [tree]) == "aloha"
+
+
+def test_nested_function_uses_locals_separator() -> None:
+    tree = _parse("def outer():\n    def inner():\n        pass\n")
+    outer = tree.body[0]
+    assert isinstance(outer, ast.FunctionDef)
+    inner = outer.body[0]
+    assert isinstance(inner, ast.FunctionDef)
+    assert reconstruct_qualname(inner, [tree, outer]) == "outer..inner"
+
+
+def test_class_method_omits_locals_separator() -> None:
+    tree = _parse("class Foo:\n    def bar(self):\n        pass\n")
+    foo = tree.body[0]
+    assert isinstance(foo, ast.ClassDef)
+    bar = foo.body[0]
+    assert isinstance(bar, ast.FunctionDef)
+    assert reconstruct_qualname(bar, [tree, foo]) == "Foo.bar"
+
+
+def test_nested_class_method_chains_class_names() -> None:
+    """UQ-WP3-01: `class A: class B: def c(): ...` yields `A.B.c`."""
+    tree = _parse("class Outer:\n    class Inner:\n        def method(self):\n            pass\n")
+    outer = tree.body[0]
+    assert isinstance(outer, ast.ClassDef)
+    inner = outer.body[0]
+    assert isinstance(inner, ast.ClassDef)
+    method = inner.body[0]
+    assert isinstance(method, ast.FunctionDef)
+    assert reconstruct_qualname(method, [tree, outer, inner]) == "Outer.Inner.method"
+
+
+def test_function_in_class_method() -> None:
+    """`class Foo: def bar(): def inner(): ...` yields `Foo.bar..inner`."""
+    source = "class Foo:\n    def bar(self):\n        def inner():\n            pass\n"
+    tree = _parse(source)
+    foo = tree.body[0]
+    assert isinstance(foo, ast.ClassDef)
+    bar = foo.body[0]
+    assert isinstance(bar, ast.FunctionDef)
+    inner = bar.body[0]
+    assert isinstance(inner, ast.FunctionDef)
+    assert reconstruct_qualname(inner, [tree, foo, bar]) == "Foo.bar..inner"
+
+
+def test_class_in_function_in_class_method() -> None:
+    """`class Foo: def bar(): class Local: def meth(): ...` yields `Foo.bar..Local.meth`.
+
+    The `` appears once — between the function parent `bar` and the
+    class parent `Local`. Class parents don't add their own ``.
+    """
+    source = (
+        "class Foo:\n"
+        "    def bar(self):\n"
+        "        class Local:\n"
+        "            def meth(self):\n"
+        "                pass\n"
+    )
+    tree = _parse(source)
+    foo = tree.body[0]
+    assert isinstance(foo, ast.ClassDef)
+    bar = foo.body[0]
+    assert isinstance(bar, ast.FunctionDef)
+    local = bar.body[0]
+    assert isinstance(local, ast.ClassDef)
+    meth = local.body[0]
+    assert isinstance(meth, ast.FunctionDef)
+    assert reconstruct_qualname(meth, [tree, foo, bar, local]) == "Foo.bar..Local.meth"
+
+
+def test_overloaded_method_gets_regular_qualname() -> None:
+    """UQ-WP3-07: @typing.overload methods are regular defs with decorators.
+
+    The decorator does not change __qualname__; each overload and the final
+    implementation all share ``Foo.bar``.
+    """
+    source = (
+        "from typing import overload\n"
+        "class Foo:\n"
+        "    @overload\n"
+        "    def bar(self, x: int) -> int: ...\n"
+        "    @overload\n"
+        "    def bar(self, x: str) -> str: ...\n"
+        "    def bar(self, x):\n"
+        "        pass\n"
+    )
+    tree = _parse(source)
+    foo = tree.body[1]  # index 0 is the `from typing import overload`
+    assert isinstance(foo, ast.ClassDef)
+    for bar_def in foo.body:
+        assert isinstance(bar_def, ast.FunctionDef)
+        assert reconstruct_qualname(bar_def, [tree, foo]) == "Foo.bar"
+
+
+def test_deeply_nested_function() -> None:
+    source = "def a():\n    def b():\n        def c():\n            pass\n"
+    tree = _parse(source)
+    a = tree.body[0]
+    assert isinstance(a, ast.FunctionDef)
+    b = a.body[0]
+    assert isinstance(b, ast.FunctionDef)
+    c = b.body[0]
+    assert isinstance(c, ast.FunctionDef)
+    assert reconstruct_qualname(c, [tree, a, b]) == "a..b..c"
diff --git a/plugins/python/tests/test_round_trip.py b/plugins/python/tests/test_round_trip.py
new file mode 100644
index 00000000..c23766ce
--- /dev/null
+++ b/plugins/python/tests/test_round_trip.py
@@ -0,0 +1,144 @@
+"""Round-trip self-test: plugin analyses its own source (WP3 Task 8).
+
+Drives the *installed* ``clarion-plugin-python`` entry-point binary
+(not ``sys.executable -m``) so the pip-install entry point is exercised
+end-to-end. The plugin's own ``extractor.py`` is the analysis target; the
+test asserts the module's public API functions appear in the returned
+entity list with the expected 3-segment L2 EntityId shape.
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sysconfig
+from pathlib import Path
+from typing import IO, Any
+
+import pytest
+
+
+def _encode_frame(payload: dict[str, Any]) -> bytes:
+    body = json.dumps(payload).encode("utf-8")
+    header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
+    return header + body
+
+
+def _read_frame(stream: IO[bytes]) -> dict[str, Any]:
+    headers: dict[str, str] = {}
+    while True:
+        line = stream.readline()
+        if not line:
+            msg = "EOF before headers terminator"
+            raise RuntimeError(msg)
+        if line in (b"\r\n", b"\n"):
+            break
+        name, _, value = line.decode("ascii").rstrip("\r\n").partition(":")
+        headers[name.strip().lower()] = value.strip()
+    length = int(headers["content-length"])
+    body = stream.read(length)
+    parsed: dict[str, Any] = json.loads(body)
+    return parsed
+
+
+def _locate_binary() -> Path:
+    scripts = Path(sysconfig.get_path("scripts"))
+    binary = scripts / "clarion-plugin-python"
+    if not binary.exists():
+        pytest.skip(
+            f"clarion-plugin-python not at {binary}; "
+            "install with `pip install -e plugins/python[dev]`",
+        )
+    return binary
+
+
+def test_round_trip_self_analysis() -> None:
+    """Plugin → analyze_file on its own extractor.py → expected entities appear."""
+    binary = _locate_binary()
+
+    # plugins/python/src is the package root; using it as project_root lets
+    # the plugin relativise extractor.py to `clarion_plugin_python/extractor.py`,
+    # whose dotted module name is `clarion_plugin_python.extractor`.
+    plugin_src = Path(__file__).resolve().parents[1] / "src"
+    target = plugin_src / "clarion_plugin_python" / "extractor.py"
+    assert target.is_file(), f"target source not found at {target}"
+
+    proc = subprocess.Popen(  # noqa: S603 - invoking our own installed entry point
+        [str(binary)],
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+    try:
+        assert proc.stdin is not None
+        assert proc.stdout is not None
+
+        # Handshake.
+        proc.stdin.write(
+            _encode_frame(
+                {
+                    "jsonrpc": "2.0",
+                    "id": 1,
+                    "method": "initialize",
+                    "params": {
+                        "protocol_version": "1.0",
+                        "project_root": str(plugin_src),
+                    },
+                },
+            ),
+        )
+        proc.stdin.flush()
+        init_response = _read_frame(proc.stdout)
+        assert init_response["id"] == 1
+        assert init_response["result"]["name"] == "clarion-plugin-python"
+
+        proc.stdin.write(
+            _encode_frame({"jsonrpc": "2.0", "method": "initialized", "params": {}}),
+        )
+        proc.stdin.flush()
+
+        # Analyze extractor.py.
+        proc.stdin.write(
+            _encode_frame(
+                {
+                    "jsonrpc": "2.0",
+                    "id": 2,
+                    "method": "analyze_file",
+                    "params": {"file_path": str(target)},
+                },
+            ),
+        )
+        proc.stdin.flush()
+        response = _read_frame(proc.stdout)
+        assert response["id"] == 2
+
+        entities = response["result"]["entities"]
+        ids = {e["id"] for e in entities}
+        # Public extractor API must be present.
+        assert "python:function:clarion_plugin_python.extractor.module_dotted_name" in ids
+        assert "python:function:clarion_plugin_python.extractor.extract" in ids
+        # Private walker is a FunctionDef too, so it emits.
+        assert "python:function:clarion_plugin_python.extractor._walk" in ids
+        assert "python:function:clarion_plugin_python.extractor._build_entity" in ids
+
+        # Every entity should carry kind="function" and the absolute
+        # source.file_path we sent (project_root relativisation only affects
+        # the qualified_name prefix, not source.file_path).
+        for entity in entities:
+            assert entity["kind"] == "function"
+            assert entity["source"]["file_path"] == str(target)
+
+        # Graceful shutdown.
+        proc.stdin.write(
+            _encode_frame({"jsonrpc": "2.0", "id": 3, "method": "shutdown", "params": {}}),
+        )
+        proc.stdin.flush()
+        _read_frame(proc.stdout)  # shutdown ack
+        proc.stdin.write(_encode_frame({"jsonrpc": "2.0", "method": "exit"}))
+        proc.stdin.flush()
+        proc.stdin.close()
+        assert proc.wait(timeout=5) == 0
+    finally:
+        if proc.poll() is None:
+            proc.kill()
+            proc.wait(timeout=2)
diff --git a/plugins/python/tests/test_server.py b/plugins/python/tests/test_server.py
new file mode 100644
index 00000000..feedda0f
--- /dev/null
+++ b/plugins/python/tests/test_server.py
@@ -0,0 +1,249 @@
+"""Integration tests for the JSON-RPC server loop (WP3 Task 2).
+
+Spawns the installed `clarion-plugin-python` binary as a subprocess, speaks
+Content-Length-framed JSON-RPC to it over stdin/stdout, and asserts the
+handshake response matches the Rust host's `InitializeResult` contract
+(`{name, version, ontology_version, capabilities}` per
+`crates/clarion-core/src/plugin/protocol.rs` line 293).
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+import textwrap
+from typing import IO, TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+# Invoke via ``sys.executable -m`` rather than the installed console script so
+# the test works regardless of whether the venv's bin dir is on $PATH when
+# pytest runs. Task 8's round-trip test exercises the entry-point binary; this
+# test only needs ``main()`` reached via the package module.
+_SERVER_CMD = [sys.executable, "-m", "clarion_plugin_python"]
+
+
+def _encode_frame(payload: dict[str, Any]) -> bytes:
+    body = json.dumps(payload).encode("utf-8")
+    header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
+    return header + body
+
+
+def _read_frame(stream: IO[bytes]) -> dict[str, Any]:
+    headers: dict[str, str] = {}
+    while True:
+        line = stream.readline()
+        if not line:
+            msg = "EOF before headers terminator"
+            raise RuntimeError(msg)
+        if line in (b"\r\n", b"\n"):
+            break
+        name, _, value = line.decode("ascii").rstrip("\r\n").partition(":")
+        headers[name.strip().lower()] = value.strip()
+    length = int(headers["content-length"])
+    body = stream.read(length)
+    parsed: dict[str, Any] = json.loads(body)
+    return parsed
+
+
+def test_initialize_roundtrip() -> None:
+    """initialize → response carries all four InitializeResult fields."""
+    proc = subprocess.Popen(  # noqa: S603 - invoking our own entry point under test
+        _SERVER_CMD,
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+    try:
+        assert proc.stdin is not None
+        assert proc.stdout is not None
+
+        request = {
+            "jsonrpc": "2.0",
+            "id": 1,
+            "method": "initialize",
+            "params": {"protocol_version": "1.0", "project_root": "/tmp"},
+        }
+        proc.stdin.write(_encode_frame(request))
+        proc.stdin.flush()
+
+        response = _read_frame(proc.stdout)
+        assert response["jsonrpc"] == "2.0"
+        assert response["id"] == 1
+        result = response["result"]
+        assert result["name"] == "clarion-plugin-python"
+        assert result["version"] == "0.1.0"
+        assert result["ontology_version"] == "0.1.0"
+        # Capabilities carry the L8 Wardline probe result. We don't pin a
+        # specific status here because the probe's output depends on whether
+        # wardline is installed in the test environment — all three legal
+        # states (`absent`, `enabled`, `version_out_of_range`) pass.
+        assert "wardline" in result["capabilities"]
+        assert result["capabilities"]["wardline"]["status"] in {
+            "absent",
+            "enabled",
+            "version_out_of_range",
+        }
+
+        # Graceful shutdown: shutdown → ack `{}`, then exit notification.
+        proc.stdin.write(
+            _encode_frame({"jsonrpc": "2.0", "id": 2, "method": "shutdown", "params": {}}),
+        )
+        proc.stdin.flush()
+        shutdown_response = _read_frame(proc.stdout)
+        assert shutdown_response["id"] == 2
+        assert shutdown_response["result"] == {}
+
+        proc.stdin.write(_encode_frame({"jsonrpc": "2.0", "method": "exit"}))
+        proc.stdin.flush()
+        proc.stdin.close()
+
+        assert proc.wait(timeout=5) == 0
+    finally:
+        if proc.poll() is None:
+            proc.kill()
+            proc.wait(timeout=2)
+
+
+def test_analyze_file_before_initialized_returns_error() -> None:
+    """Per JSON-RPC semantics, analyze_file without preceding initialized is rejected."""
+    proc = subprocess.Popen(  # noqa: S603
+        _SERVER_CMD,
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+    try:
+        assert proc.stdin is not None
+        assert proc.stdout is not None
+
+        proc.stdin.write(
+            _encode_frame(
+                {
+                    "jsonrpc": "2.0",
+                    "id": 1,
+                    "method": "analyze_file",
+                    "params": {"file_path": "/tmp/foo.py"},
+                },
+            ),
+        )
+        proc.stdin.flush()
+
+        response = _read_frame(proc.stdout)
+        assert response["id"] == 1
+        assert "error" in response
+        assert response["error"]["code"] == -32002
+
+        # Tear down.
+        proc.stdin.close()
+        proc.wait(timeout=5)
+    finally:
+        if proc.poll() is None:
+            proc.kill()
+            proc.wait(timeout=2)
+
+
+def test_analyze_file_returns_extracted_entities(tmp_path: Path) -> None:
+    """After initialize, analyze_file on a real .py file yields function entities."""
+    demo = tmp_path / "demo.py"
+    demo.write_text(
+        textwrap.dedent("""
+        def hello():
+            pass
+
+        class Foo:
+            def bar(self):
+                pass
+    """).lstrip()
+    )
+
+    proc = subprocess.Popen(  # noqa: S603
+        _SERVER_CMD,
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+    try:
+        assert proc.stdin is not None
+        assert proc.stdout is not None
+
+        # Handshake with project_root = tmp_path so the plugin relativises paths.
+        proc.stdin.write(
+            _encode_frame(
+                {
+                    "jsonrpc": "2.0",
+                    "id": 1,
+                    "method": "initialize",
+                    "params": {
+                        "protocol_version": "1.0",
+                        "project_root": str(tmp_path),
+                    },
+                },
+            ),
+        )
+        proc.stdin.flush()
+        _read_frame(proc.stdout)  # initialize response
+        proc.stdin.write(
+            _encode_frame({"jsonrpc": "2.0", "method": "initialized", "params": {}}),
+        )
+        proc.stdin.flush()
+
+        # Analyze the file.
+        proc.stdin.write(
+            _encode_frame(
+                {
+                    "jsonrpc": "2.0",
+                    "id": 2,
+                    "method": "analyze_file",
+                    "params": {"file_path": str(demo)},
+                },
+            ),
+        )
+        proc.stdin.flush()
+        response = _read_frame(proc.stdout)
+        assert response["id"] == 2
+        entities = response["result"]["entities"]
+        ids = {e["id"] for e in entities}
+        assert ids == {
+            "python:function:demo.hello",
+            "python:function:demo.Foo.bar",
+        }
+
+        proc.stdin.close()
+        proc.wait(timeout=5)
+    finally:
+        if proc.poll() is None:
+            proc.kill()
+            proc.wait(timeout=2)
+
+
+def test_method_not_found_returns_error() -> None:
+    """Unknown method → -32601 response, server stays up."""
+    proc = subprocess.Popen(  # noqa: S603
+        _SERVER_CMD,
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+    try:
+        assert proc.stdin is not None
+        assert proc.stdout is not None
+
+        proc.stdin.write(
+            _encode_frame(
+                {"jsonrpc": "2.0", "id": 1, "method": "bogus_method", "params": {}},
+            ),
+        )
+        proc.stdin.flush()
+
+        response = _read_frame(proc.stdout)
+        assert response["error"]["code"] == -32601
+
+        proc.stdin.close()
+        proc.wait(timeout=5)
+    finally:
+        if proc.poll() is None:
+            proc.kill()
+            proc.wait(timeout=2)
diff --git a/plugins/python/tests/test_stdout_guard.py b/plugins/python/tests/test_stdout_guard.py
new file mode 100644
index 00000000..996877bf
--- /dev/null
+++ b/plugins/python/tests/test_stdout_guard.py
@@ -0,0 +1,54 @@
+"""WP2 UQ-WP2-08 plugin-side enforcement: stdout is reserved for JSON-RPC.
+
+The guard is tested via subprocess because ``install_stdio()`` mutates
+``sys.stdout`` in the running interpreter, which would break pytest's own
+output capture if run in-process.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+
+
+def test_install_stdio_blocks_print() -> None:
+    """After install_stdio(), a Python `print()` raises StdoutGuardError."""
+    code = (
+        "from clarion_plugin_python.stdout_guard import install_stdio, StdoutGuardError\n"
+        "import sys\n"
+        "install_stdio()\n"
+        "try:\n"
+        "    print('should not reach host')\n"
+        "except StdoutGuardError:\n"
+        "    sys.stderr.write('guard-fired\\n')\n"
+        "    sys.exit(42)\n"
+        "sys.exit(0)\n"
+    )
+    proc = subprocess.run(  # noqa: S603
+        [sys.executable, "-c", code],
+        check=False,
+        capture_output=True,
+        timeout=5,
+    )
+    assert proc.returncode == 42, (
+        f"expected guard-fired exit 42, got {proc.returncode}; stderr={proc.stderr!r}"
+    )
+    assert b"guard-fired" in proc.stderr
+
+
+def test_install_stdio_returns_real_streams() -> None:
+    """install_stdio() yields usable stdin/stdout bytes streams."""
+    code = (
+        "from clarion_plugin_python.stdout_guard import install_stdio\n"
+        "stdin, stdout = install_stdio()\n"
+        "stdout.write(b'raw-bytes-out')\n"
+        "stdout.flush()\n"
+    )
+    proc = subprocess.run(  # noqa: S603
+        [sys.executable, "-c", code],
+        check=False,
+        capture_output=True,
+        timeout=5,
+    )
+    assert proc.returncode == 0, f"stderr={proc.stderr!r}"
+    assert proc.stdout == b"raw-bytes-out"
diff --git a/plugins/python/tests/test_wardline_probe.py b/plugins/python/tests/test_wardline_probe.py
new file mode 100644
index 00000000..c0609d09
--- /dev/null
+++ b/plugins/python/tests/test_wardline_probe.py
@@ -0,0 +1,131 @@
+"""Unit tests for the L8 Wardline probe (WP3 Task 6).
+
+Each case stubs ``importlib.import_module`` inside the probe module to
+simulate the absent / in-range / out-of-range states without requiring
+the real ``wardline`` package to be present or absent in the test
+environment.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import TYPE_CHECKING
+
+from clarion_plugin_python.wardline_probe import probe
+
+if TYPE_CHECKING:
+    import pytest
+
+
+def _install_fake_import(
+    monkeypatch: pytest.MonkeyPatch,
+    *,
+    wardline_module: object | None,
+    registry_module: object | None,
+) -> None:
+    """Replace ``importlib.import_module`` as seen by wardline_probe."""
+
+    def fake_import(name: str) -> object:
+        if name == "wardline.core.registry":
+            if registry_module is None:
+                msg = "no wardline.core.registry"
+                raise ImportError(msg)
+            return registry_module
+        if name == "wardline":
+            if wardline_module is None:
+                msg = "no wardline"
+                raise ImportError(msg)
+            return wardline_module
+        msg = f"unexpected import: {name}"
+        raise ImportError(msg)
+
+    # String target bypasses mypy's re-export check on
+    # `clarion_plugin_python.wardline_probe.importlib`.
+    monkeypatch.setattr(
+        "clarion_plugin_python.wardline_probe.importlib.import_module",
+        fake_import,
+    )
+
+
+def test_probe_absent_when_registry_import_fails(monkeypatch: pytest.MonkeyPatch) -> None:
+    _install_fake_import(monkeypatch, wardline_module=None, registry_module=None)
+    assert probe("0.1.0", "0.2.0") == {"status": "absent"}
+
+
+def test_probe_enabled_when_version_in_range(monkeypatch: pytest.MonkeyPatch) -> None:
+    fake_wardline = SimpleNamespace(__version__="0.1.5")
+    fake_registry = SimpleNamespace(REGISTRY={})
+    _install_fake_import(
+        monkeypatch,
+        wardline_module=fake_wardline,
+        registry_module=fake_registry,
+    )
+    assert probe("0.1.0", "0.2.0") == {"status": "enabled", "version": "0.1.5"}
+
+
+def test_probe_at_lower_bound_is_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Lower bound is inclusive."""
+    fake_wardline = SimpleNamespace(__version__="0.1.0")
+    fake_registry = SimpleNamespace(REGISTRY={})
+    _install_fake_import(
+        monkeypatch,
+        wardline_module=fake_wardline,
+        registry_module=fake_registry,
+    )
+    assert probe("0.1.0", "0.2.0") == {"status": "enabled", "version": "0.1.0"}
+
+
+def test_probe_at_upper_bound_is_out_of_range(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Upper bound is exclusive."""
+    fake_wardline = SimpleNamespace(__version__="0.2.0")
+    fake_registry = SimpleNamespace(REGISTRY={})
+    _install_fake_import(
+        monkeypatch,
+        wardline_module=fake_wardline,
+        registry_module=fake_registry,
+    )
+    assert probe("0.1.0", "0.2.0") == {"status": "version_out_of_range", "version": "0.2.0"}
+
+
+def test_probe_above_upper_bound_is_out_of_range(monkeypatch: pytest.MonkeyPatch) -> None:
+    fake_wardline = SimpleNamespace(__version__="0.3.0")
+    fake_registry = SimpleNamespace(REGISTRY={})
+    _install_fake_import(
+        monkeypatch,
+        wardline_module=fake_wardline,
+        registry_module=fake_registry,
+    )
+    assert probe("0.1.0", "0.2.0") == {"status": "version_out_of_range", "version": "0.3.0"}
+
+
+def test_probe_absent_when_version_attribute_missing(monkeypatch: pytest.MonkeyPatch) -> None:
+    fake_wardline = SimpleNamespace()  # no __version__
+    fake_registry = SimpleNamespace(REGISTRY={})
+    _install_fake_import(
+        monkeypatch,
+        wardline_module=fake_wardline,
+        registry_module=fake_registry,
+    )
+    assert probe("0.1.0", "0.2.0") == {"status": "absent"}
+
+
+def test_probe_absent_when_version_is_not_a_string(monkeypatch: pytest.MonkeyPatch) -> None:
+    fake_wardline = SimpleNamespace(__version__=123)
+    fake_registry = SimpleNamespace(REGISTRY={})
+    _install_fake_import(
+        monkeypatch,
+        wardline_module=fake_wardline,
+        registry_module=fake_registry,
+    )
+    assert probe("0.1.0", "0.2.0") == {"status": "absent"}
+
+
+def test_probe_absent_when_version_is_not_valid_semver(monkeypatch: pytest.MonkeyPatch) -> None:
+    fake_wardline = SimpleNamespace(__version__="not-a-version")
+    fake_registry = SimpleNamespace(REGISTRY={})
+    _install_fake_import(
+        monkeypatch,
+        wardline_module=fake_wardline,
+        registry_module=fake_registry,
+    )
+    assert probe("0.1.0", "0.2.0") == {"status": "absent"}
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 00000000..906629d3
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,4 @@
+[toolchain]
+channel = "stable"
+components = ["rustfmt", "clippy", "llvm-tools-preview"]
+profile = "minimal"
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 00000000..ebae4973
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1,5 @@
+edition = "2024"
+max_width = 100
+newline_style = "Unix"
+use_field_init_shorthand = true
+use_try_shorthand = true
diff --git a/tests/e2e/sprint_1_walking_skeleton.sh b/tests/e2e/sprint_1_walking_skeleton.sh
new file mode 100755
index 00000000..ccefc3bb
--- /dev/null
+++ b/tests/e2e/sprint_1_walking_skeleton.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+# Sprint 1 walking-skeleton end-to-end demo (WP3 Task 9 / signoffs A.4).
+#
+# Runs the README §3 demo script end-to-end and verifies:
+#   - `clarion install` creates `.clarion/clarion.db`
+#   - `clarion analyze .` spawns the Python plugin and persists at least one entity
+#   - `sqlite3 .clarion/clarion.db` returns `python:function:demo.hello|function`
+#
+# Dependencies: cargo, Python 3.11+, sqlite3 CLI.
+#
+# Env overrides:
+#   REPO_ROOT   — auto-detected via `git rev-parse`; override to test an external checkout.
+#   VENV        — defaults to $REPO_ROOT/plugins/python/.venv; override to reuse an existing venv.
+#   CARGO_BUILD — set to "0" to skip `cargo build` (assumes target/release/clarion already present).
+
+set -euo pipefail
+
+REPO_ROOT="${REPO_ROOT:-$(git rev-parse --show-toplevel)}"
+VENV="${VENV:-$REPO_ROOT/plugins/python/.venv}"
+CARGO_BUILD="${CARGO_BUILD:-1}"
+
+log() { printf '[walking-skeleton] %s\n' "$*" >&2; }
+fail() { printf '[walking-skeleton] FAIL: %s\n' "$*" >&2; exit 1; }
+
+cd "$REPO_ROOT"
+
+# ── 1. Build clarion binary ──────────────────────────────────────────────────
+if [ "$CARGO_BUILD" = "1" ]; then
+    log "building clarion (release) ..."
+    cargo build --workspace --release
+fi
+CLARION_BIN="$REPO_ROOT/target/release/clarion"
+[ -x "$CLARION_BIN" ] || fail "clarion binary missing at $CLARION_BIN"
+
+# ── 2. Install Python plugin (editable) ──────────────────────────────────────
+if [ ! -d "$VENV" ]; then
+    log "creating venv at $VENV ..."
+    python3 -m venv "$VENV"
+fi
+log "installing clarion-plugin-python (editable) ..."
+"$VENV/bin/pip" install --quiet -e "$REPO_ROOT/plugins/python[dev]"
+PLUGIN_BIN="$VENV/bin/clarion-plugin-python"
+[ -x "$PLUGIN_BIN" ] || fail "clarion-plugin-python missing at $PLUGIN_BIN"
+PLUGIN_MANIFEST="$VENV/share/clarion/plugins/python/plugin.toml"
+[ -f "$PLUGIN_MANIFEST" ] || fail "plugin.toml missing at $PLUGIN_MANIFEST (WP2 L9 install-prefix fallback)"
+
+# ── 3. Scratch project ───────────────────────────────────────────────────────
+DEMO_DIR="$(mktemp -d -t clarion-demo-XXXXXX)"
+trap 'rm -rf "$DEMO_DIR"' EXIT
+log "scratch project: $DEMO_DIR"
+cd "$DEMO_DIR"
+echo 'def hello(): return "world"' > demo.py
+
+# ── 4. PATH wiring — clarion + plugin binary ────────────────────────────────
+export PATH="$REPO_ROOT/target/release:$VENV/bin:$PATH"
+
+# ── 5. clarion install ───────────────────────────────────────────────────────
+log "running: clarion install"
+clarion install
+[ -f "$DEMO_DIR/.clarion/clarion.db" ] || fail ".clarion/clarion.db not created by clarion install"
+
+# ── 6. clarion analyze ───────────────────────────────────────────────────────
+log "running: clarion analyze ."
+clarion analyze .
+
+# ── 7. Verify entity via sqlite3 ─────────────────────────────────────────────
+log "verifying persisted entity via sqlite3 ..."
+RESULT=$(sqlite3 "$DEMO_DIR/.clarion/clarion.db" "select id, kind from entities order by id;")
+EXPECTED="python:function:demo.hello|function"
+
+if [ "$RESULT" != "$EXPECTED" ]; then
+    log "DB contents:"
+    sqlite3 "$DEMO_DIR/.clarion/clarion.db" "select * from entities;" >&2 || true
+    fail "expected exactly '$EXPECTED', got '$RESULT'"
+fi
+
+log "PASS: walking skeleton persisted $RESULT"