diff --git a/.rivets-wsix/design.md b/.rivets-wsix/design.md
new file mode 100644
index 0000000..6580e55
--- /dev/null
+++ b/.rivets-wsix/design.md
@@ -0,0 +1,95 @@
+# rivets-wsix design: cascade-correctness regression fences
+
+## Purpose
+
+The wsix audit found **zero bugs** (see `what-i-learned.md`). The schema's
+`ON DELETE CASCADE` chain plus per-file `DELETE FROM symbols WHERE file_id`
+quietly handles re-index correctness for refs, attributes, and other tables —
+not the `clear_all_X` pattern lcb6 established. The remaining risk is **schema
+drift**: a future change to the cascade FKs, or a new INSERT site added without
+a paired clear or cascade, would silently re-introduce the bug class wsix
+worried about.
+
+This design defines integration-test fences that lock in the current
+correctness so CI catches schema drift before it merges.
+
+## Approach
+
+**One new integration test file**: `crates/tethys/tests/reindex_cascade.rs`.
+
+Each test exercises one cascade or per-file-clear invariant against a tiny
+tempdir fixture, using `Tethys::index()` (the production entry point) and
+direct SQLite queries (independent of the indexing pipeline's internal state).
+Pattern is the same as `pass2_qualified_paths.rs` from PR #74: `tempfile` +
+`workspace_with_files` helper + `rusqlite` for the oracle.
+
+## Falsification
+
+| # | Claim | Falsifier | Oracle | Cost | Status | Regression fence |
+|---|-------|-----------|--------|------|--------|------------------|
+| 1 | After a file's source is mutated to remove a function-body call, re-indexing without DB reset removes the corresponding row from `refs` (the `in_symbol_id`-cascade chain works). | Construct a fixture with `fn entry() { helper::a(); helper::b(); }`. Index. Mutate source to remove `helper::b()`. Re-index. Count refs in entry's file. Expected: count drops by exactly 1. If unchanged or higher, claim false. | Manually count call expressions in the new source via inspection; SQL `COUNT(*)` against the post-mutation DB. | 5 min | **passed** (probe_refs_bug.sh observed 2 → 3 → 1) | New integration test `refs_cascade_on_call_removal` in `reindex_cascade.rs`. |
+| 2 | After a file's source is mutated to remove an entire function definition that carried `#[some_attr]`, re-indexing without DB reset removes BOTH the symbol row AND its `attributes` row (`attributes` cascades via `symbols(id) ON DELETE CASCADE`). | Fixture with `#[allow(dead_code)] fn target() {}`. Index. Capture `(symbol_count, attribute_count)`. Remove the `target` fn from source. Re-index. Capture again. Expected: both decrease by exactly 1. | Direct SQL count: `SELECT COUNT(*) FROM symbols WHERE name = 'target'` and `SELECT COUNT(*) FROM attributes a JOIN symbols s ON s.id = a.symbol_id WHERE s.name = 'target'`. | 5 min | pending | New integration test `attributes_cascade_on_symbol_removal` in `reindex_cascade.rs`. |
+| 3 | Re-indexing an unchanged workspace produces stable `call_edges` and `file_deps` counts (the existing `clear_all_X` discipline works; lcb6 + sibling). | Fixture with two files, one calling the other (so call_edges and file_deps have rows). Index twice with no source change. Compare counts. Expected: equal across runs. If second run is greater, the clear_all is missing or not called. | Direct SQL count: `SELECT COUNT(*) FROM call_edges` and `SELECT COUNT(*) FROM file_deps`. | 5 min | pending | New integration test `clear_all_tables_stable_under_reindex` in `reindex_cascade.rs`. Already partially covered by `file_deps_idempotency.rs` (rivets-lcb6) — this test adds the call_edges twin and the joint stability assertion. |
+
+## Self-review (per the v1.0.3 falsifiable-design checklist)
+
+### 1. Claim count
+3 claims. In the 3-15 healthy band. Each defends a distinct invariant.
+
+### 2. Falsifier independence
+- Claim 1's oracle is direct SQL `COUNT(*)` and tree-sitter-independent manual call enumeration. Probe used Bash + sqlite3 CLI; the test will use Rust + rusqlite. Different mechanisms.
+- Claim 2's oracle is direct SQL queries against `symbols` and `attributes`. Indexer is the SUT; the SQL is independent.
+- Claim 3's oracle is direct SQL `COUNT(*)`. Independent of indexer.
+
+### 3. Falsifier non-vacuity
+For each claim, a concrete buggy implementation that would make the fence fail:
+
+- **Claim 1**: if `refs.in_symbol_id REFERENCES symbols(id) ON DELETE CASCADE` were silently relaxed to `... ON DELETE SET NULL` (or the FK dropped entirely) in a future migration, the cascade wouldn't fire and removed refs would persist. The test asserts a specific count decrease — would fail directly.
+- **Claim 2**: if a future schema migration changed `attributes.symbol_id REFERENCES symbols(id) ON DELETE CASCADE` to `ON DELETE NO ACTION`, removed symbols would leave orphan attribute rows. Test would observe `attribute_count` unchanged after symbol removal.
+- **Claim 3**: if a future change to `index_with_options` accidentally removed the `clear_all_file_deps()` call at `indexing.rs:139`, file_deps would accumulate across runs and the test's count-stability assertion would fail.
+
+Vacuity checks (per the v1.0.3 anti-pattern list):
+- No `column LIKE '%...%' AND symbol_id IS NOT NULL` filters (the I1 shape from PR #74).
+- No disjunctive assertions where one disjunct is also asserted standalone (the R2-1 shape).
+- All assertions are direct count equalities. TDD inversion: each test would fail under exactly one specific code mutation (the cascade FK removal for Claim 1/2, the `clear_all` removal for Claim 3). None passes-when-bug-present.
+
+### 4. Per-claim verification distinctness
+- Claim 1's failure message: "refs count for src/lib.rs did not decrease after removing helper::b()". Localizes to the in_symbol_id cascade.
+- Claim 2's failure message: "attributes count for symbol 'target' did not decrease after removing the function definition". Localizes to the symbols→attributes cascade.
+- Claim 3's failure message: "call_edges count or file_deps count grew across unchanged-source re-index runs". Localizes to the clear_all discipline.
+Each test has a distinct failure mode that names the failing invariant.
+
+### 5. Cost distribution
+All three falsifiers are ≤ 5 min each. Cheap, deterministic, CI-friendly. No expensive falsifiers in the design.
+
+### 6. Negative space (what this design deliberately does NOT do)
+
+- **Does not fix any bug.** The audit found zero. This is a regression-fence-only design.
+- **Does not cover the orphan-file-from-disk case.** Files deleted from disk leave `files` rows behind; their cascade-dependent rows persist. Tracked separately as **rivets-dhxo**. This design's fixtures all retain the original file paths to isolate the per-file re-index path from the orphan-file path.
+- **Does not test the streaming-mode (`IndexOptions::with_streaming()`) indexing variant.** Default full-index path only. Streaming has divergent behavior per dhxo's analysis.
+- **Does not add an architectural meta-fence** (e.g., "every new INSERT site in `db/*.rs` must have a paired clear path"). That would be a more invasive test (parsing source code at test time) and is properly its own follow-up issue. If a future PR introduces a new bug-class table, the dhxo-style review process should catch it — but a meta-fence would be belt-and-braces. Filed considerations:
+ - Could be a separate rivets task post-merge if the team wants it.
+ - As written, the three fences here are surface-only: they catch cascade or clear_all *regressions* on the audited tables. They do not catch new bug-class tables added in the future.
+
+### 7. Tracker references
+
+- This design references **rivets-wsix** (this issue) and **rivets-dhxo** (orphan-file boundary) and **rivets-lcb6** (the file_deps fix that established the precedent). All three are real tracker entries verified by `rivets show`.
+- No "defer to follow-up" language in the design body. The architectural meta-fence consideration in #6 is explicitly out of scope, not deferred — meaning it can be picked up later if desired, but is not a tracked follow-up obligation. If the user decides post-review to file it, it becomes a new issue ID; otherwise it lives in this design as out-of-scope rationale.
+
+## What's NOT in this design (consolidated)
+
+1. No production code changes — fence-only.
+2. No coverage of `IndexOptions::with_streaming()` mode (dhxo territory).
+3. No architectural meta-fence for new INSERT sites (see #6).
+4. No coverage of cross-crate or `--lsp` Pass-3 cascade behavior (Pass 2 and earlier only).
+
+## Output of falsifiable-design (hard gate)
+
+- [x] Probe and oracle agreed on the smallest slice (see `what-i-learned.md`)
+- [x] Each claim has a falsifier
+- [x] At least one falsifier has been run (Claim 1 via probe_refs_bug.sh — passed)
+- [x] Self-review checklist applied (sections 1-7 above)
+- [x] All claims falsifiable
+- [x] All tracker references verified to exist
+
+Ready for budgeted-plan.
diff --git a/.rivets-wsix/oracle.sh b/.rivets-wsix/oracle.sh
new file mode 100644
index 0000000..3df3fcb
--- /dev/null
+++ b/.rivets-wsix/oracle.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# rivets-wsix oracle: independent enumeration of tethys's tables via schema
+# DDL, then audit ALL insert sites in crates/tethys/src/ for each table.
+#
+# Different mechanism from probe.sh:
+# - Probe assumed 1 table per file (db/
.rs convention).
+# - Oracle reads actual CREATE TABLE statements from schema.rs, then
+# greps for INSERT INTO
across the whole tethys crate.
+# - Catches tables that don't follow the file naming convention
+# (subordinate tables like enum_variants, struct_fields per PR #58).
+#
+# Output: for each schema-declared table, list every INSERT site found
+# and flag UPSERT shape.
+
+set -euo pipefail
+cd "$(git rev-parse --show-toplevel)"
+
+SCHEMA_FILE="crates/tethys/src/db/schema.rs"
+
+# Extract table names from CREATE TABLE statements (handle quoted, IF NOT EXISTS, etc.)
+tables=$(grep -oE "CREATE TABLE (IF NOT EXISTS )?[a-z_]+" "$SCHEMA_FILE" \
+ | awk '{print $NF}' | sort -u)
+
+echo "=== Tables declared in $SCHEMA_FILE ==="
+echo "$tables"
+echo
+
+echo "=== INSERT sites per table ==="
+for t in $tables; do
+ # Find INSERT INTO
( or INSERT OR REPLACE INTO
+ sites=$(grep -rnE "INSERT( OR REPLACE)? INTO ${t}\b" \
+ crates/tethys/src --include="*.rs" 2>/dev/null || true)
+ if [[ -z "$sites" ]]; then
+ printf "%-30s [NO INSERT SITES FOUND]\n" "$t"
+ continue
+ fi
+ while IFS= read -r line; do
+ file_line=$(echo "$line" | cut -d: -f1-2)
+ sql=$(echo "$line" | cut -d: -f3- | sed 's/^[[:space:]]*//' \
+ | cut -c1-100)
+ upsert_marker=""
+ # Check if the line OR a nearby line (within 5 lines) has UPSERT shape
+ loc=$(echo "$line" | cut -d: -f1-2)
+ fpath=$(echo "$loc" | cut -d: -f1)
+ lnum=$(echo "$loc" | cut -d: -f2)
+ end=$((lnum + 5))
+ nearby=$(sed -n "${lnum},${end}p" "$fpath" 2>/dev/null || true)
+ if echo "$nearby" | grep -qE "ON CONFLICT.*DO UPDATE|INSERT OR REPLACE"; then
+ upsert_marker=" [UPSERT]"
+ fi
+ printf "%-30s %s%s\n" "$t" "$file_line" "$upsert_marker"
+ done <<< "$sites"
+done
+
+echo
+echo "=== clear_all_X function definitions ==="
+grep -rnE "fn clear_all_[a-z_]+" crates/tethys/src --include="*.rs" \
+ | sed 's|crates/tethys/src/||'
diff --git a/.rivets-wsix/plan.md b/.rivets-wsix/plan.md
new file mode 100644
index 0000000..993370a
--- /dev/null
+++ b/.rivets-wsix/plan.md
@@ -0,0 +1,328 @@
+# rivets-wsix budgeted plan
+
+Three slices, one per design claim. All slices are pure-test additions (no production code changes). Single new file `crates/tethys/tests/reindex_cascade.rs`. Per-slice budgets reflect test execution time, not production runtime.
+
+## Slice 1: refs cascade on call removal
+
+**Claim:** [design C1] After a file's source is mutated to remove a function-body call, re-indexing without DB reset removes the corresponding row from `refs`.
+
+**Oracle:** Direct SQL `SELECT COUNT(*) FROM refs r JOIN files f ON f.id=r.file_id WHERE f.path='src/lib.rs'`. Independent of the indexer: the assertion reads SQLite directly, not through any tethys API layer.
+
+**Stress fixture:**
+Starting source: `pub fn entry() { helper::a(); helper::b(); helper::c(); }` → 3 cross-file call refs.
+Mutated source: `pub fn entry() { helper::a(); helper::c(); }` → 2 calls (removed the MIDDLE one).
+Adversarial intent:
+- If the cascade chain were "wipe all refs and re-insert," count goes 3 → 2. ✓ test passes.
+- If the cascade missed the middle ref specifically (some weird "first match wins" bug), count goes 3 → 3 or 3 → 1. ✗ test fails.
+- If no clearing happened, count goes 3 → 5 (2 new + 3 stale). ✗ test fails.
+The exact-count assertion `== 2` distinguishes all three. The middle-removal shape defeats a hypothetical "head-only" or "tail-only" cascade bug.
+
+Also assert: the surviving refs target `helper::a` and `helper::c` specifically (not `helper::b`). Catches the "cascade ran but kept the wrong refs" failure mode.
+
+**Loop budget:** N/A — the slice adds no production loops; only test code.
+
+**Wall budget:** ≤ 5s per test (target for nextest CI). Each `Tethys::index()` call on this fixture is sub-second on the laptop in earlier probes.
+
+**Files:**
+- `crates/tethys/tests/reindex_cascade.rs` (new, this slice creates it)
+
+**Code (advisory):**
+```rust
+//! Regression fences for rivets-wsix: cascade-correctness across re-index runs.
+//!
+//! The wsix audit (see .rivets-wsix/what-i-learned.md) found that re-index
+//! correctness for `refs`, `attributes`, and `symbols` relies on the schema's
+//! ON DELETE CASCADE chain, not the `clear_all_X` pattern. These tests lock
+//! in that cascade-correctness so a future schema change (e.g., relaxing a
+//! cascade FK to SET NULL or NO ACTION) is caught in CI.
+
+use rusqlite::params;
+
+mod common;
+use common::{open_db, workspace_with_files};
+
+/// Pin claim 1: removing a function-body call from a file's source produces
+/// exactly one row removal from `refs` after re-index, via the
+/// `refs.in_symbol_id REFERENCES symbols(id) ON DELETE CASCADE` chain.
+#[test]
+fn refs_cascade_on_call_removal() {
+ let (dir, mut tethys) = workspace_with_files(&[
+ ("Cargo.toml", r#"
+[package]
+name = "wsix_refs"
+version = "0.0.0"
+edition = "2021"
+"#),
+ ("src/lib.rs", r"
+mod helper;
+
+pub fn entry() {
+ helper::a();
+ helper::b();
+ helper::c();
+}
+"),
+ ("src/helper.rs", r"
+pub fn a() {}
+pub fn b() {}
+pub fn c() {}
+"),
+ ]);
+
+ tethys.index().expect("initial index");
+
+ let conn = open_db(&tethys);
+ let refs_pre: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM refs r JOIN files f ON f.id=r.file_id JOIN symbols s ON s.id=r.symbol_id
+ WHERE f.path='src/lib.rs' AND s.name IN ('a','b','c')",
+ params![], |row| row.get(0),
+ ).expect("count pre");
+
+ // Sanity: indexer produced what we expected.
+ assert_eq!(refs_pre, 3, "fixture should produce 3 cross-file call refs");
+
+ // Mutate source: remove the MIDDLE call. Belt-and-braces mtime bump so
+ // tethys doesn't skip-by-hash.
+ std::fs::write(dir.path().join("src/lib.rs"), r"
+mod helper;
+
+pub fn entry() {
+ helper::a();
+ helper::c();
+}
+").expect("rewrite");
+ std::thread::sleep(std::time::Duration::from_secs(1));
+ filetime::set_file_mtime(dir.path().join("src/lib.rs"),
+ filetime::FileTime::now()).expect("touch");
+
+ tethys.index().expect("re-index");
+
+ let conn = open_db(&tethys);
+ let refs_post: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM refs r JOIN files f ON f.id=r.file_id JOIN symbols s ON s.id=r.symbol_id
+ WHERE f.path='src/lib.rs' AND s.name IN ('a','b','c')",
+ params![], |row| row.get(0),
+ ).expect("count post");
+
+ assert_eq!(refs_post, 2, "expected 2 refs (a,c) after removing b()");
+
+ // Stronger: the surviving refs target a and c specifically.
+ let b_refs: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM refs r JOIN files f ON f.id=r.file_id JOIN symbols s ON s.id=r.symbol_id
+ WHERE f.path='src/lib.rs' AND s.name='b'",
+ params![], |row| row.get(0),
+ ).expect("count b");
+ assert_eq!(b_refs, 0, "ref to helper::b must be gone after source removal");
+}
+```
+
+If `filetime` is not already a dev-dependency, fall back to deleting + recreating the file (which definitively changes mtime). If even that doesn't trigger re-index, use `Tethys::index_with_options(IndexOptions::rebuild())` — but that defeats the purpose since `--rebuild` does `db.reset()`. The first option (filetime) is the surgical answer.
+
+**Verification:**
+- [ ] `cargo nextest run -p tethys --test reindex_cascade refs_cascade_on_call_removal` passes
+- [ ] Mutate the assertion to `assert_eq!(refs_post, 3)` (the "bug present" case) and confirm the test fails — TDD-inversion proves non-vacuity
+- [ ] `cargo clippy -p tethys --tests -- -D warnings` clean
+
+---
+
+## Slice 2: symbols → attributes cascade on symbol removal
+
+**Claim:** [design C2] Removing an attributed function from source removes both the symbol row AND its attribute rows; cascade `attributes.symbol_id REFERENCES symbols(id) ON DELETE CASCADE` fires.
+
+**Oracle:** Direct SQL count against `symbols` and `attributes` tables, joined by `s.id = a.symbol_id`. Independent of indexer.
+
+**Stress fixture:**
+Two attributed symbols. `#[allow(dead_code)] fn target() {}` (the one we'll remove) and `#[allow(dead_code)] fn keep() {}` (the one that stays). Remove `target` from source.
+
+Adversarial intent:
+- Correct cascade: target gone, target's attributes gone, keep still there, keep's attributes still there. Final counts: `symbols where name='target'` = 0, `attributes joined on symbols where s.name='target'` = 0, `symbols where name='keep'` = 1, `attributes joined on symbols where s.name='keep'` ≥ 1.
+- Cascade too aggressive (clears all attributes for the file, not just for the deleted symbol): keep's attributes also drop to 0. Test catches.
+- Cascade too narrow / not firing: target's attribute rows persist with stale `symbol_id`. Test catches via the target-attribute count assertion.
+
+**Loop budget:** N/A.
+
+**Wall budget:** ≤ 5s.
+
+**Files:**
+- `crates/tethys/tests/reindex_cascade.rs` (modify — append a new `#[test] fn attributes_cascade_on_symbol_removal`)
+
+**Code (advisory):**
+```rust
+/// Pin claim 2: removing an attributed symbol from source removes the symbol
+/// AND its attribute rows via `attributes.symbol_id ON DELETE CASCADE`. The
+/// keep-symbol's attributes must remain.
+#[test]
+fn attributes_cascade_on_symbol_removal() {
+ let (dir, mut tethys) = workspace_with_files(&[
+ ("Cargo.toml", /* same as slice 1 */ ),
+ ("src/lib.rs", r"
+#[allow(dead_code)]
+pub fn target() {}
+
+#[allow(dead_code)]
+pub fn keep() {}
+"),
+ ]);
+
+ tethys.index().expect("initial");
+
+ let conn = open_db(&tethys);
+ let target_attrs_pre: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM attributes a JOIN symbols s ON s.id=a.symbol_id WHERE s.name='target'",
+ params![], |row| row.get(0)).expect("count");
+ let keep_attrs_pre: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM attributes a JOIN symbols s ON s.id=a.symbol_id WHERE s.name='keep'",
+ params![], |row| row.get(0)).expect("count");
+
+ assert!(target_attrs_pre >= 1, "fixture should index target's #[allow] attribute");
+ assert!(keep_attrs_pre >= 1, "fixture should index keep's #[allow] attribute");
+
+ // Remove target from source.
+ std::fs::write(dir.path().join("src/lib.rs"), r"
+#[allow(dead_code)]
+pub fn keep() {}
+").expect("rewrite");
+ std::thread::sleep(std::time::Duration::from_secs(1));
+ filetime::set_file_mtime(dir.path().join("src/lib.rs"),
+ filetime::FileTime::now()).expect("touch");
+ tethys.index().expect("re-index");
+
+ let conn = open_db(&tethys);
+ let target_attrs_post: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM attributes a JOIN symbols s ON s.id=a.symbol_id WHERE s.name='target'",
+ params![], |row| row.get(0)).expect("count post");
+ let keep_attrs_post: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM attributes a JOIN symbols s ON s.id=a.symbol_id WHERE s.name='keep'",
+ params![], |row| row.get(0)).expect("count post");
+ let target_sym_post: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM symbols WHERE name='target'",
+ params![], |row| row.get(0)).expect("count");
+
+ assert_eq!(target_sym_post, 0, "target symbol must be gone after source removal");
+ assert_eq!(target_attrs_post, 0, "target's attributes must cascade-delete with the symbol");
+ assert_eq!(keep_attrs_post, keep_attrs_pre,
+ "keep's attributes must NOT cascade-delete (cascade was too aggressive)");
+}
+```
+
+**Verification:**
+- [ ] Test passes on current main
+- [ ] TDD-inversion: temporarily change `target_attrs_post` assert to `== keep_attrs_pre` (i.e., expect bug); confirm test fails
+- [ ] Clippy clean
+
+---
+
+## Slice 3: clear_all stability on unchanged-source re-index
+
+**Claim:** [design C3] Running `Tethys::index()` twice on an unchanged workspace produces identical row counts in `call_edges` and `file_deps` (the existing `clear_all_X` discipline holds).
+
+**Oracle:** Direct SQL `SELECT COUNT(*) FROM call_edges` and `SELECT COUNT(*) FROM file_deps`. Independent.
+
+**Stress fixture:**
+Two-file workspace where lib.rs calls helper.rs's function (producing one call_edge AND one file_dep). Index twice. Assert both counts EQUAL between runs.
+
+Adversarial intent:
+- Working `clear_all`: count1 == count2. Test passes.
+- `clear_all_file_deps` removed from `index_with_options`: file_deps doubles to 2. Test fails.
+- `clear_all_call_edges` removed: call_edges doubles to 2. Test fails.
+- A UPSERT bug where ref_count keeps incrementing without new rows: row counts equal, but per-row `ref_count` doubles. Add a third assertion on `SELECT SUM(ref_count) FROM file_deps` to catch this.
+
+**Loop budget:** N/A.
+
+**Wall budget:** ≤ 5s.
+
+**Files:**
+- `crates/tethys/tests/reindex_cascade.rs` (modify — append `#[test] fn clear_all_tables_stable_under_reindex`)
+
+**Code (advisory):**
+```rust
+/// Pin claim 3: re-indexing an unchanged workspace produces stable counts in
+/// `call_edges` and `file_deps`. Catches regression of the `clear_all_X`
+/// discipline (rivets-lcb6's fix for file_deps, plus call_edges).
+#[test]
+fn clear_all_tables_stable_under_reindex() {
+ let (_dir, mut tethys) = workspace_with_files(&[
+ ("Cargo.toml", /* ... */ ),
+ ("src/lib.rs", r"
+mod helper;
+
+pub fn entry() {
+ helper::do_thing();
+}
+"),
+ ("src/helper.rs", r"pub fn do_thing() {}"),
+ ]);
+
+ tethys.index().expect("first");
+
+ let conn = open_db(&tethys);
+ let ce1: i64 = conn.query_row("SELECT COUNT(*) FROM call_edges", params![], |row| row.get(0)).expect("ce1");
+ let fd1: i64 = conn.query_row("SELECT COUNT(*) FROM file_deps", params![], |row| row.get(0)).expect("fd1");
+ let fd_sum1: i64 = conn.query_row(
+ "SELECT COALESCE(SUM(ref_count), 0) FROM file_deps", params![], |row| row.get(0)
+ ).expect("fd_sum1");
+
+ assert!(ce1 >= 1, "fixture should produce at least one call_edge");
+ assert!(fd1 >= 1, "fixture should produce at least one file_dep");
+
+ // Re-index without source change. Same fixture, same files, no mtime bump.
+ // tethys may skip-by-hash, which is FINE for this test — the assertion
+ // we care about is that *if* the indexer touches these tables again, the
+ // clear_all_X path runs before re-insertion. Force a re-index by calling
+ // index() again; under the current implementation, even skipped files'
+ // dependencies are recomputed.
+ tethys.index().expect("second");
+
+ let conn = open_db(&tethys);
+ let ce2: i64 = conn.query_row("SELECT COUNT(*) FROM call_edges", params![], |row| row.get(0)).expect("ce2");
+ let fd2: i64 = conn.query_row("SELECT COUNT(*) FROM file_deps", params![], |row| row.get(0)).expect("fd2");
+ let fd_sum2: i64 = conn.query_row(
+ "SELECT COALESCE(SUM(ref_count), 0) FROM file_deps", params![], |row| row.get(0)
+ ).expect("fd_sum2");
+
+ assert_eq!(ce1, ce2, "call_edges count must not grow across unchanged-source re-index");
+ assert_eq!(fd1, fd2, "file_deps count must not grow across unchanged-source re-index");
+ assert_eq!(fd_sum1, fd_sum2, "file_deps SUM(ref_count) must not grow either (UPSERT-aggregate fence)");
+}
+```
+
+**Verification:**
+- [ ] Test passes on current main
+- [ ] TDD-inversion: comment out `self.db.clear_all_file_deps()` in `indexing.rs:139`; confirm `fd_sum1 == fd_sum2` assertion fails (this is the lcb6 regression check)
+- [ ] Clippy clean
+
+---
+
+## Plan Self-Review
+
+### 1. Every loop in the plan
+- None of the 3 slices adds a new production loop. All slices are test-only. Test-execution loops (over fixture files, over assertions) are bounded by fixture size (≤ 5 files, ≤ 5 assertions per test). No budget concern.
+
+### 2. Every fixture
+- Slice 1: middle-removal stress, designed to fail under "wrong-cascade-row removal" bugs. Not happy-path.
+- Slice 2: two-symbol-different-attribute-status, designed to fail under both "cascade missed" and "cascade too wide" bugs. Not happy-path.
+- Slice 3: cross-file call (covers both call_edges and file_deps) plus a `SUM(ref_count)` assertion, designed to fail under both "clear_all missing" and "UPSERT-aggregate growth without new rows." Not happy-path.
+
+### 3. Every doc-comment precondition
+None of the slices introduces a doc-commented precondition. The slices ARE the regression fences for an already-documented (in `what-i-learned.md`) invariant. No new contracts; no enforcement-strength classification needed.
+
+### 4. Every write target
+- All asserts go through `panic!` (via the `assert*!` macros) and `nextest` captures them to stderr. That's diagnostic. No `println!`/`eprintln!` introduced.
+
+### 5. Every tracker reference
+The plan references:
+- **rivets-wsix** (the active issue — verified open and in-progress)
+- **rivets-lcb6** (closed, the precedent fix — referenced for context, no deferred work)
+- **rivets-dhxo** (open, orphan-file boundary — explicit out-of-scope per design.md, not deferred FROM this work)
+
+No "TODO" or "follow-up" trigger phrases in the plan body. All slices ship in this PR or get rejected; none are deferred.
+
+## Hard gate
+
+- [x] Every slice has all mandatory fields filled in (Claim, Oracle, Stress fixture, Loop budget, Wall budget, Files, Code, Verification)
+- [x] Every loop has a complexity statement (N/A noted explicitly for each slice — no new production loops)
+- [x] Every slice has a stress fixture
+- [x] Plan's claim coverage matches design's claim list (3 slices ↔ 3 design claims, 1:1)
+- [x] Every tracker reference resolves to an existing issue
+
+Ready for checkpointed-build.
diff --git a/.rivets-wsix/probe.sh b/.rivets-wsix/probe.sh
new file mode 100644
index 0000000..da25263
--- /dev/null
+++ b/.rivets-wsix/probe.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# rivets-wsix probe: enumerate UPSERT-shaped writes in crates/tethys/src/db/
+# and pair each table with whether it has a clear_all_* function defined.
+#
+# Mechanism: regex grep over .rs source. Tables come from the file name
+# (db/
.rs convention); UPSERT shape detected by SQL pattern in source.
+#
+# Output columns:
+# FILE | HAS_UPSERT | HAS_CLEAR_FN | CLEAR_FN_NAME
+
+set -euo pipefail
+cd "$(git rev-parse --show-toplevel)"
+
+DB_DIR="crates/tethys/src/db"
+
+for f in "$DB_DIR"/*.rs; do
+ base=$(basename "$f" .rs)
+ [[ "$base" == "mod" ]] && continue
+
+ has_upsert="no"
+ if grep -qE "INSERT[[:space:]]+OR[[:space:]]+REPLACE|ON[[:space:]]+CONFLICT.*DO[[:space:]]+UPDATE" "$f"; then
+ has_upsert="yes"
+ fi
+
+ clear_fn=$(grep -oE "fn clear_all_[a-z_]+" "$f" | head -1 || true)
+ has_clear="no"
+ if [[ -n "$clear_fn" ]]; then
+ has_clear="yes"
+ fi
+
+ printf '%-30s upsert=%-4s clear_fn=%-4s %s\n' "$base" "$has_upsert" "$has_clear" "${clear_fn:--}"
+done
diff --git a/.rivets-wsix/probe_null_in_symbol.sh b/.rivets-wsix/probe_null_in_symbol.sh
new file mode 100644
index 0000000..6adc0c3
--- /dev/null
+++ b/.rivets-wsix/probe_null_in_symbol.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+# rivets-wsix probe slice 3: do refs with in_symbol_id IS NULL accumulate
+# across re-index runs? These are file-scope refs (mod declarations, top-level
+# type annotations) not contained in any function/struct/impl, so they don't
+# cascade-delete via the symbol DELETE that catches in-function refs.
+
+set -euo pipefail
+cd "$(git rev-parse --show-toplevel)"
+
+WORK=$(mktemp -d)
+trap "rm -rf $WORK" EXIT
+
+mkdir -p "$WORK/src"
+cat > "$WORK/Cargo.toml" << 'EOF'
+[package]
+name = "probe_null_in_symbol"
+version = "0.0.0"
+edition = "2021"
+EOF
+
+# Run 1: file with mod declarations and use statements (file-scope refs).
+cat > "$WORK/src/lib.rs" << 'EOF'
+mod helper_a;
+mod helper_b;
+EOF
+cat > "$WORK/src/helper_a.rs" << 'EOF'
+pub fn do_a() {}
+EOF
+cat > "$WORK/src/helper_b.rs" << 'EOF'
+pub fn do_b() {}
+EOF
+
+TETHYS="$(pwd)/target/release/tethys"
+cd "$WORK"
+
+count_null_refs() {
+ sqlite3 .rivets/index/tethys.db \
+ "SELECT COUNT(*) FROM refs r JOIN files f ON f.id=r.file_id
+ WHERE f.path='src/lib.rs' AND r.in_symbol_id IS NULL;"
+}
+count_total_lib_refs() {
+ sqlite3 .rivets/index/tethys.db \
+ "SELECT COUNT(*) FROM refs r JOIN files f ON f.id=r.file_id
+ WHERE f.path='src/lib.rs';"
+}
+
+"$TETHYS" index --workspace . > /dev/null 2>&1
+N1_NULL=$(count_null_refs)
+N1_TOTAL=$(count_total_lib_refs)
+
+# Run 2: REMOVE mod helper_b; (delete one of the two mod-level refs).
+cat > src/lib.rs << 'EOF'
+mod helper_a;
+EOF
+sleep 1
+touch src/lib.rs
+
+"$TETHYS" index --workspace . > /dev/null 2>&1
+N2_NULL=$(count_null_refs)
+N2_TOTAL=$(count_total_lib_refs)
+
+# Run 3: change to a DIFFERENT module name entirely.
+cat > src/lib.rs << 'EOF'
+mod helper_a;
+mod helper_c;
+EOF
+cat > src/helper_c.rs << 'EOF'
+pub fn do_c() {}
+EOF
+sleep 1
+touch src/lib.rs
+
+"$TETHYS" index --workspace . > /dev/null 2>&1
+N3_NULL=$(count_null_refs)
+N3_TOTAL=$(count_total_lib_refs)
+
+echo "=== src/lib.rs refs (null in_symbol_id only) across re-index runs ==="
+echo "Run 1 (mod helper_a + mod helper_b): null=$N1_NULL total=$N1_TOTAL expected null≥2"
+echo "Run 2 (only mod helper_a): null=$N2_NULL total=$N2_TOTAL expected null=1 if cleared, 2 if buggy"
+echo "Run 3 (mod helper_a + mod helper_c): null=$N3_NULL total=$N3_TOTAL expected null=2 if cleared, ≥3 if buggy"
+echo
+echo "=== dump of in_symbol_id NULL refs in src/lib.rs after run 3 ==="
+sqlite3 .rivets/index/tethys.db \
+ "SELECT r.reference_name, s.name AS symbol_name
+ FROM refs r JOIN files f ON f.id=r.file_id
+ LEFT JOIN symbols s ON s.id=r.symbol_id
+ WHERE f.path='src/lib.rs' AND r.in_symbol_id IS NULL;"
+echo
+
+if [[ "$N2_NULL" -gt 1 || "$N3_NULL" -gt 2 ]]; then
+ echo "BUG CONFIRMED: file-scope refs (in_symbol_id IS NULL) persist after source removal."
+else
+ echo "OK: file-scope refs also reflect current source state."
+fi
diff --git a/.rivets-wsix/probe_refs_bug.sh b/.rivets-wsix/probe_refs_bug.sh
new file mode 100644
index 0000000..9c3dbb7
--- /dev/null
+++ b/.rivets-wsix/probe_refs_bug.sh
@@ -0,0 +1,108 @@
+#!/usr/bin/env bash
+# rivets-wsix probe (expanded slice): does `refs` actually accumulate across
+# re-index runs?
+#
+# Smallest factual question: if I index a fixture twice without changing
+# anything, does the total number of refs in the DB double?
+#
+# Mechanism (probe): run `tethys index` twice on a tiny tempdir workspace,
+# count rows in `refs` table via direct SQLite query between runs.
+#
+# Oracle: count refs from the indexing.rs trace logs themselves
+# (RUST_LOG=tethys=trace). Independent because the trace fires once per
+# extracted ref, regardless of what the DB does on insert.
+
+set -euo pipefail
+cd "$(git rev-parse --show-toplevel)"
+
+WORK=$(mktemp -d)
+trap "rm -rf $WORK" EXIT
+
+# Tiny fixture: one file, two refs (one resolved same-file, one cross-file).
+mkdir -p "$WORK/src"
+cat > "$WORK/Cargo.toml" << 'EOF'
+[package]
+name = "probe_refs_bug"
+version = "0.0.0"
+edition = "2021"
+EOF
+cat > "$WORK/src/lib.rs" << 'EOF'
+mod helper;
+
+pub fn entry() {
+ helper::do_thing();
+ let x = make_thing();
+}
+
+fn make_thing() -> i32 { 42 }
+EOF
+cat > "$WORK/src/helper.rs" << 'EOF'
+pub fn do_thing() {}
+EOF
+
+# Build tethys release binary if not already
+TETHYS="$(pwd)/target/release/tethys"
+if [[ ! -x "$TETHYS" ]]; then
+ cargo build -p tethys --release --quiet 2>&1 | tail -5
+fi
+
+cd "$WORK"
+
+count_refs() {
+ sqlite3 .rivets/index/tethys.db \
+ "SELECT COUNT(*) FROM refs r JOIN files f ON f.id=r.file_id
+ WHERE f.path='src/lib.rs';"
+}
+
+# Run 1: initial index. 3 refs in lib.rs: helper, do_thing, make_thing.
+"$TETHYS" index --workspace . > /dev/null 2>&1
+N1=$(count_refs)
+
+# Modify src/lib.rs and re-index. Force a content change AND a mtime bump.
+cat > src/lib.rs << 'EOF'
+mod helper;
+
+pub fn entry() {
+ helper::do_thing();
+ let x = make_thing();
+ let y = make_thing();
+}
+
+fn make_thing() -> i32 { 42 }
+EOF
+# Belt-and-braces mtime bump (some filesystems have 1s mtime resolution).
+sleep 1
+touch src/lib.rs
+
+# Run 2: now lib.rs has an EXTRA call to make_thing (4 refs total expected).
+"$TETHYS" index --workspace . > /dev/null 2>&1
+N2=$(count_refs)
+
+# Now REMOVE both make_thing calls and re-index.
+cat > src/lib.rs << 'EOF'
+mod helper;
+
+pub fn entry() {
+ helper::do_thing();
+}
+
+fn make_thing() -> i32 { 42 }
+EOF
+sleep 1
+touch src/lib.rs
+
+"$TETHYS" index --workspace . > /dev/null 2>&1
+N3=$(count_refs)
+
+echo "=== refs in src/lib.rs across re-index runs ==="
+echo "Run 1 (helper::do_thing + 1× make_thing call): $N1 expected ~3 (mod, fn, fn)"
+echo "Run 2 (helper::do_thing + 2× make_thing call): $N2 expected ~4 (mod, fn, fn, fn)"
+echo "Run 3 (helper::do_thing only, no make_thing): $N3 expected ~2 if cleared, ~5+ if buggy"
+echo
+if [[ "$N3" -gt "$N2" || "$N3" -gt 3 ]]; then
+ echo "BUG CONFIRMED: removing refs from source leaves stale rows in refs table."
+elif [[ "$N3" -lt "$N2" ]]; then
+ echo "OK: refs table correctly reflects current source state (some clear path exists)."
+else
+ echo "AMBIGUOUS: refs count unchanged. Source change may not have been re-indexed."
+fi
diff --git a/.rivets-wsix/related-issues.md b/.rivets-wsix/related-issues.md
new file mode 100644
index 0000000..32ecac2
--- /dev/null
+++ b/.rivets-wsix/related-issues.md
@@ -0,0 +1,23 @@
+# Related issues — rivets-wsix
+
+Tracker scan (5-min cap per prove-it-prototype step 0):
+
+## Direct ancestors
+
+- **rivets-lcb6** (closed, PR #65) — the *parent* fix. Discovered file_deps was UPSERT-only and never cleared, causing stale resolver edges to accumulate. Established the `clear_all_X` pattern and applied it to `file_deps`. The wsix audit asks: "what other tables have this shape?"
+- **rivets-zoi3** (open, P2) — also surfaced during PR #65 review. Asks for *expanded test coverage* of the file_deps clear pattern (4 specific tests). Adjacent but different work; wsix is breadth-first (which tables), zoi3 is depth-first (one table, many fixtures).
+
+## Sibling / related
+
+- **rivets-dhxo** (open, P3) — orphan file_deps re-insertion in streaming-mode indexing. Different bug class (orphan file_ids producing phantom edges *from* deleted source files), but lives in the same "file_deps coherence" problem space. Not displaced by wsix.
+
+## Closed but informative
+
+- **rivets-itz7** (closed, P1) — added imports indexing for Rust + C#.
+- **rivets-lxbg** (closed, P1) — added imports table to schema.
+
+Both are about *creating* the imports table; neither audited its write pattern for the UPSERT-stale-row bug class. The imports table is a wsix candidate (it appears in my broader grep).
+
+## No prior art found for
+
+The wsix-specific question — "which UPSERT-only tables in `crates/tethys/src/db/` are missing a `clear_all_X` call from `index_with_options`" — has no existing issue. wsix is the right tracker entry; no displacement.
diff --git a/.rivets-wsix/review-decisions-round-1.md b/.rivets-wsix/review-decisions-round-1.md
new file mode 100644
index 0000000..5fb0887
--- /dev/null
+++ b/.rivets-wsix/review-decisions-round-1.md
@@ -0,0 +1,31 @@
+# PR #75 review-feedback decisions — round 1
+
+## Reviewers
+- **claude-review** (GitHub Action bot, run 26074529893)
+- **gemini-code-assist** (GitHub bot)
+
+## Per-finding table
+
+| # | Finding (one line) | Reviewer | Category | Verified? | Decision | Note |
+|---|---|---|---|---|---|---|
+| 1 | `refs_cascade_on_call_removal` doesn't explicitly assert `c_refs == 1` (only `a_refs == 1` and `b_refs == 0`) — surviving-ref coverage is asymmetric | claude-review | Polish | Yes (read test at `reindex_cascade.rs:101-117`; `c_refs` derivable from `refs_post − a_refs − b_refs = 2 − 1 − 0 = 1` but not asserted) | **Accept** | 4-line addition; matches the existing `a_refs`/`b_refs` pattern. Strengthens against a future-mutation bug class where someone adds a name to the IN-clause and breaks the arithmetic. |
+| 2 | `count_lib_refs_by_target_names` joins on `r.symbol_id` (callee), but the cascade fires on `r.in_symbol_id` (container) — indirect oracle | claude-review | Design (informational) | Yes (read SQL at `reindex_cascade.rs:17-29`; reviewer themselves notes this is fine in practice because the fixture resolves cleanly) | **Reject** | The reviewer explicitly classified this as a future-maintainer note, not actionable. The indirection is *the point* — the test verifies the *effect* of the cascade (refs disappear), not the trigger mechanism, which is the right level for a regression fence. Changing to `r.in_symbol_id` would actually weaken the fence by testing the trigger instead of the consequence. |
+| 3 | `plan.md` mentions `filetime` / `std::thread::sleep` but the impl uses content-hash change detection (no `filetime` dep) | claude-review | Polish (doc drift) | Yes (`.rivets-wsix/plan.md` does reference filetime; implementation in `reindex_cascade.rs` writes new content, no filetime usage) | **Reject (intentional)** | Per CLAUDE.md "Issue diagnostic directories" convention: "Point-in-time, not maintained. Probe scripts query whatever schema/state was current when the fix was developed; they will go stale and that's expected." The plan documents the design *as drafted*; the implementation legitimately took a simpler path during build, which is exactly what `checkpointed-build` permits ("the implementer is permitted to deviate if the deviation keeps the slice within budget and the oracle passing"). Leaving the plan as-is preserves the historical record of "we considered filetime, picked content-hash instead." |
+| 4 | (no findings) | gemini-code-assist | n/a | n/a | n/a | "I have no feedback to provide as there were no review comments." |
+
+## Statistics
+
+- Findings: 3 actionable + 1 explicit no-comment = 4 total
+- Accept: 1
+- Modify: 0
+- Reject: 2 (with rationale, no deferral — both are conscious design choices, not "do later")
+
+Per the skill's red-flag check on "six findings, six accepts": healthy reject rate
+(2/3 of actionable findings rejected with explicit rationale). Indicates per-finding
+verification actually happened.
+
+## Outcome
+
+- Applied: claim 1 (`c_refs == 1` assertion).
+- Documentation: this file.
+- No new tracker issues filed — no deferred work in this round.
diff --git a/.rivets-wsix/what-i-learned.md b/.rivets-wsix/what-i-learned.md
new file mode 100644
index 0000000..002b738
--- /dev/null
+++ b/.rivets-wsix/what-i-learned.md
@@ -0,0 +1,59 @@
+# What I learned — rivets-wsix prove-it-prototype
+
+## One-sentence summary
+
+The schema's `ON DELETE CASCADE` chain plus per-file `DELETE FROM symbols WHERE file_id` is the actual safety net for re-index correctness across most tables — not the `clear_all_X` pattern lcb6 established for `file_deps` and `call_edges`. The wsix issue's mental model ("UPSERT-only ⇒ needs `clear_all_X`") was a special case, not the general principle.
+
+## Per-table inventory after three probe expansions
+
+| Table | Insert shape | Re-index safety mechanism | Audit result |
+|---|---|---|---|
+| `call_edges` | UPSERT `ON CONFLICT DO UPDATE` | `clear_all_call_edges` at `indexing.rs:439` | ✓ Safe |
+| `file_deps` | UPSERT `ON CONFLICT DO UPDATE` | `clear_all_file_deps` at `indexing.rs:139` (lcb6 fix) | ✓ Safe |
+| `imports` | UPSERT `INSERT OR REPLACE` | `DELETE FROM imports WHERE file_id` at `files.rs:146` before per-file re-insert | ✓ Safe |
+| `symbols` | Plain `INSERT` | `DELETE FROM symbols WHERE file_id` at `files.rs:145` | ✓ Safe |
+| `attributes` | Plain `INSERT` | Cascade from `symbols(id) ON DELETE CASCADE` when symbols are wiped per file | ✓ Safe |
+| `refs` | Plain `INSERT`, no explicit clear | Cascade via `refs.in_symbol_id REFERENCES symbols(id) ON DELETE CASCADE` (and the `symbol_id` FK for same-file refs) when the containing file's symbols are wiped | ✓ Safe per probe |
+| `files` | `INSERT` after `SELECT id WHERE path = ?` existence check | UPSERT-by-existence-check; row id is reused so cascade-dependent rows keep stable file_id | ✓ Per-file safe |
+| `arch_packages` | Plain `INSERT` | `DELETE FROM arch_packages` at start of `repopulate_architecture` (architecture.rs:61); cascade clears the two child tables | ✓ Safe |
+| `arch_file_packages` | Plain `INSERT` | Cascade from `arch_packages(id)` | ✓ Safe |
+| `arch_package_deps` | Plain `INSERT` | Cascade from `arch_packages(id)` | ✓ Safe |
+
+**Zero new bugs found.** wsix's mental model expected the file_deps shape (monotonic aggregate growth) on other tables. Empirically, none of them exhibit it.
+
+## What surprised me
+
+1. **The `refs` table has no explicit clear and no UPSERT, yet handles re-indexing correctly.** My initial code reading said "this should accumulate." Probe disproved that. The cascade chain via `in_symbol_id → symbols(id)` is wider than I appreciated — when a file's symbols are wiped, *every* ref contained in those symbols cascade-deletes, regardless of whether the ref's target is in the same file.
+
+2. **Tethys's rust extractor doesn't generate refs for `mod X;` declarations.** I expected `mod helper_a; mod helper_b;` to produce 2 file-scope refs (in_symbol_id IS NULL). The probe showed 0. So the in_symbol_id IS NULL edge case — which would have been the only path stale refs could persist — doesn't exist in practice. (This may be worth a separate audit if `extern crate X;` or top-level type aliases produce file-scope refs that mod declarations don't; out of scope for wsix.)
+
+3. **`imports` uses BOTH `INSERT OR REPLACE` AND a per-file `DELETE`.** Belt-and-suspenders. The DELETE alone would be sufficient; the `OR REPLACE` covers a narrower edge case (same-file duplicate `use` statements). Not a bug, just redundant. Worth noting if anyone simplifies.
+
+4. **The wsix issue's classification (a/b/c) missed a fourth class**: tables that are correct *because of schema cascade*, not because of explicit clear-or-UPSERT logic. The `refs`, `attributes`, and `symbols`-via-files-cascade tables all live there.
+
+## Probe / oracle independence
+
+- **Initial probe**: regex grep on `db/*.rs` for `INSERT ... ON CONFLICT DO UPDATE` paired with `fn clear_all_*`. Assumed 1 table per file.
+- **Oracle 1**: enumerate `CREATE TABLE` statements from `schema.rs`, then grep `INSERT INTO
` across `crates/tethys/src/`. Caught the multi-table `files.rs`, the `refs`-vs-`references.rs` naming mismatch, and the `arch_*` family.
+- **Probe 2 (empirical, refs accumulation)**: run `tethys index --workspace .` twice on a tempdir fixture, then thrice with source mutations, comparing `SELECT COUNT(*) FROM refs WHERE file_id = ?`. Disproved my "refs accumulates" hypothesis.
+- **Probe 3 (empirical, in_symbol_id IS NULL case)**: same shape, fixture with `mod helper_a; mod helper_b;` at file scope. Showed extractor doesn't produce these refs.
+
+Probe-vs-oracle disagreement on the *table inventory question*: probe under-detected (file-name assumption). Resolved by adopting the oracle's view as canonical for the inventory. Then probes 2 and 3 directly verified empirical behavior on the tables the oracle inventory raised concerns about.
+
+## Implication for the gilfoyle loop
+
+The audit found no bugs to fix. The remaining value is **regression fences** that lock in the audited correctness properties so they survive future schema or indexing changes. Candidates:
+
+- Test: re-index without source change → `refs` count for that file is unchanged (not doubled). Pins the cascade.
+- Test: remove a function from a file, re-index → its refs disappear (not orphan). Pins the per-file cascade chain.
+- Test: remove `mod X;` then re-index → no orphan refs to X. Pins the (currently empty) file-scope case.
+- Test: each non-UPSERT INSERT site (`refs`, `attributes`, `symbols`, `files`) has either a documented cascade path OR a per-file clear. Could be an architecture test that fails CI if a new INSERT is added without a corresponding clear/cascade declaration.
+
+The falsifiable-design step should choose which of these fences to ship.
+
+## What's NOT in this audit
+
+- **Orphan-file behavior** (file deleted from disk, files.id persists): out of scope for wsix per its explicit framing as a sibling of lcb6 (UPSERT growth class). Tracked separately as **rivets-dhxo**.
+- **Streaming-mode (`IndexOptions::with_streaming()`) indexing**: only tested the default full-index path. dhxo's analysis suggests streaming has its own divergent behavior here.
+- **Concurrent re-index**: only single-threaded `tethys index` runs. SQLite's `busy_timeout` (per CLAUDE.md) protects against concurrent writers but cascade ordering under concurrent reads/writes wasn't probed.
+- **Schema migration scenarios**: a future schema change that drops or alters a cascade FK could silently introduce the bug class wsix was looking for. The regression fences should be schema-aware enough to catch that.
diff --git a/.rivets/issues.jsonl b/.rivets/issues.jsonl
index 5fc89e7..47c7c05 100644
--- a/.rivets/issues.jsonl
+++ b/.rivets/issues.jsonl
@@ -1,302 +1,302 @@
-{"id":"rivets-95l","title":"Add local quality gates using cargo-husky","description":"Add cargo husky and formatting, linting, and testing as pre-commit gates","status":"closed","priority":2,"issue_type":"task","assignee":"Claude","labels":[],"design":"Implemented pre-commit hook combining beads sync and cargo quality gates:\n\n**Hook Location**: `.git/hooks/pre-commit`\n\n**Quality Checks**:\n1. Beads sync flush (if in beads workspace)\n2. `cargo fmt -- --check` - Code formatting\n3. `cargo clippy --all-targets --all-features -- -D warnings` - Linting\n4. `cargo test --quiet` - All tests\n\n**Stub Code Handling**: Added `#[allow(dead_code)]` attributes to placeholder types/functions to prevent false positives during development.\n\n**Manual Execution**: Hook can be tested by running `.git/hooks/pre-commit` directly.","acceptance_criteria":"✓ Pre-commit hook installed at .git/hooks/pre-commit\n✓ Runs cargo fmt check before commits\n✓ Runs cargo clippy with -D warnings before commits\n✓ Runs cargo test before commits\n✓ Integrates with existing beads sync hook\n✓ Documentation added to README.md\n✓ Successfully passes all checks on current codebase","notes":null,"external_ref":null,"dependencies":[],"created_at":"2025-11-17T21:55:30.160010922Z","updated_at":"2025-11-17T22:04:29.413337487Z","closed_at":"2025-11-17T22:04:29.413337487Z"}
-{"id":"rivets-3l14","title":"Compute content hash for indexed files","description":"The schema supports `content_hash` but it's always `None`. See `batch_writer.rs:238`, `lib.rs:643`, `lib.rs:803`.\n\nContent hashing would provide more reliable change detection than mtime/size alone, useful for:\n- Detecting changes after file touch without content change\n- Cache key generation\n- Detecting identical content in different files","status":"open","priority":3,"issue_type":"feature","assignee":null,"labels":["enhancement","tethys"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-j9bu","dep_type":"parent-child"}],"created_at":"2026-01-30T00:18:21.382922447Z","updated_at":"2026-01-30T00:18:21.382922447Z","closed_at":null}
+{"id":"rivets-limz","title":"Audit tethys tracing log messages for structured-field consistency","description":"Project convention (per CLAUDE.md 'Structured Logging with tracing') is structured fields, not string interpolation in the message. PR #63 added some logs that bake the operation name into prose ('compute_dependencies: file not in any known crate; skipping') rather than using a structured field like operation = \"compute_dependencies\". Codebase has a mix of both styles.","status":"open","priority":4,"issue_type":"task","assignee":null,"labels":[],"design":"## Concrete examples flagged in PR #63 review\n\nThe three new trace/debug sites added in PR #63 use prose-style operation names:\n\n- resolve.rs::resolve_refs_for_file: 'File not in any known crate; skipping Pass-2-imports'\n- indexing.rs::compute_dependencies: 'compute_dependencies: file not in any known crate; skipping'\n- indexing.rs::compute_dependencies_from_stored: 'compute_dependencies_from_stored: file not in any known crate; skipping'\n\nProject convention would prefer:\n\n```rust\ndebug!(\n operation = \"compute_dependencies\",\n file = %current_file.display(),\n \"File not in any known crate; skipping\"\n);\n```\n\n## Scope\n\nThis is broader than just PR #63 sites. The whole tracing surface in tethys should be audited. CLAUDE.md provides examples of the desired pattern; grep for tracing macros and identify call sites that don't match.\n\n## Why P4\n\nCosmetic. Doesn't affect correctness. Worth doing during a quiet maintenance window, not as part of a bug fix PR.","acceptance_criteria":"- [ ] Inventory all tracing call sites in crates/tethys/src/\n- [ ] For each, classify: matches structured-field convention OR uses string interpolation\n- [ ] Update string-interpolated sites to use structured fields per CLAUDE.md\n- [ ] Bonus: add a lint or test that flags the pattern (optional)","notes":null,"external_ref":null,"dependencies":[],"created_at":"2026-05-12T23:40:44.092737600Z","updated_at":"2026-05-12T23:40:44.092737600Z","closed_at":null}
+{"id":"rivets-d06","title":"Write tests for MCP tools","description":"Integration and end-to-end tests for the MCP server:\n- Integration tests with actual MCP protocol messages\n- Multi-workspace scenario tests\n- Error response format verification\n- Full workflow tests (create -> update -> close)\n- Test with real InMemoryStorage (not mocks)","status":"closed","priority":3,"issue_type":"task","assignee":null,"labels":[],"design":null,"acceptance_criteria":"- [ ] Integration test harness for MCP protocol\n- [ ] Tests for complete issue lifecycle via MCP\n- [ ] Multi-workspace context switching tests\n- [ ] Error response format matches MCP spec\n- [ ] All tools tested with real storage backend","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-4dw","dep_type":"parent-child"}],"created_at":"2025-11-29T01:16:45.284084681Z","updated_at":"2025-11-30T18:09:34.327560198Z","closed_at":"2025-11-30T18:09:34.327560198Z"}
+{"id":"rivets-bxom","title":"tethys: implement proper incremental update and staleness check","description":"CodeIndex::update() currently re-indexes everything instead of doing a proper incremental update. CodeIndex::needs_update() always returns true. Implement proper staleness detection and incremental re-indexing.","status":"open","priority":3,"issue_type":"task","assignee":null,"labels":[],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[],"created_at":"2026-03-19T01:41:04.826229488Z","updated_at":"2026-03-19T01:41:04.826229488Z","closed_at":null}
+{"id":"rivets-j20","title":"Verify path traversal protection in rivets directory resolution","description":"Audit and verify that the rivets directory resolution logic properly handles path traversal attempts like \"../\" in user inputs. Add tests to confirm that malicious paths cannot escape the intended directory boundaries.","status":"open","priority":2,"issue_type":"task","assignee":null,"labels":["pr-feedback","security"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-p9oz","dep_type":"parent-child"}],"created_at":"2025-11-30T04:11:42.751822201Z","updated_at":"2025-11-30T04:11:42.751822201Z","closed_at":null}
+{"id":"rivets-6yc","title":"Evaluate lock holding duration in MCP tools","description":"Review the lock holding patterns in rivets-mcp tools to ensure we're not holding locks longer than necessary.\n\nContext from PR review:\n- Current pattern: Acquire context read lock, then acquire storage write lock for operations\n- Potential optimization: Clone the Arc early and release the context lock immediately\n- Trade-off: Earlier lock release vs additional Arc clone overhead\n\nEvaluation should consider:\n1. Whether the current lock ordering prevents deadlocks (context -> storage)\n2. If cloning Arc early provides meaningful concurrency benefits\n3. Actual contention patterns in typical MCP usage (single client vs multiple)\n4. Whether read locks on context could be held across storage operations safely","status":"open","priority":3,"issue_type":"task","assignee":null,"labels":["concurrency","performance","rivets-mcp"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-9po8","dep_type":"parent-child"}],"created_at":"2025-11-29T07:29:38.632289389Z","updated_at":"2025-11-29T07:29:38.632289389Z","closed_at":null}
+{"id":"rivets-08u","title":"Implement map transformations for JsonlQuery","description":"Add map() method to JsonlQuery for transforming records during streaming.\n\nThis enables type transformations and projections on JSONL data.","status":"open","priority":2,"issue_type":"task","assignee":null,"labels":[],"design":"```rust\npub struct JsonlQuery {\n predicates: Vec bool + Send + Sync>>,\n transform: Option U + Send + Sync>>,\n _phantom: PhantomData<(T, U)>,\n}\n\nimpl JsonlQuery\nwhere\n T: DeserializeOwned + 'static,\n U: 'static,\n{\n pub fn map(self, transform: F) -> JsonlQuery\n where\n F: Fn(U) -> V + Send + Sync + 'static,\n V: 'static,\n {\n JsonlQuery {\n predicates: self.predicates,\n transform: Some(Box::new(move |t| transform(\n self.transform.as_ref()\n .map(|f| f(t))\n .unwrap_or(t)\n ))),\n _phantom: PhantomData,\n }\n }\n}\n```\n\nNote: This requires refactoring JsonlQuery to support type transformations.","acceptance_criteria":"- map() method implemented\n- Supports type transformations\n- Can chain multiple map() calls\n- Transformations applied during streaming\n- Unit tests verify transformations\n- Integration test with filter + map pipeline","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-83j","dep_type":"blocks"},{"depends_on_id":"rivets-sx8j","dep_type":"parent-child"}],"created_at":"2025-11-27T23:17:01.111641956Z","updated_at":"2025-11-27T23:17:01.111641956Z","closed_at":null}
+{"id":"rivets-lye5","title":"Snapshot and diff: track structural graph changes over time","description":"Save lightweight snapshots of the graph and compare them to current state. Useful before/after refactors, in CI to audit what a PR added or removed structurally, and as a baseline for regression detection.\n\n**Inspired by:** KiroGraph's snapshot save / snapshot diff / kirograph_diff MCP tool. Storage is just JSON arrays of node IDs and edge tuples; diff is set-difference, O(n) regardless of codebase size.\n\n**Implementation:**\n- Storage: .rivets/snapshots/{label}.json with serialized list of (qualified_name, kind) for nodes and (caller_qn, callee_qn, kind) for edges. JSON over native binary because they're small and human-readable.\n- Default snapshot label is the timestamp; named labels (e.g. 'pre-refactor') are first-class.\n- Diff is computed as two set differences: added = current - snapshot, removed = snapshot - current.\n- Public API:\n - Tethys::save_snapshot(label) -> Result\n - Tethys::list_snapshots() -> Result>\n - Tethys::diff_snapshot(label) -> Result\n- CLI:\n - tethys snapshot save [LABEL]\n - tethys snapshot list\n - tethys snapshot diff [LABEL] [--format full|summary|json]\n\n**CI use case:**\ngh pr create … then tethys snapshot save pre-pr; merge; tethys snapshot diff pre-pr to audit structural changes.","status":"open","priority":3,"issue_type":"feature","assignee":null,"labels":["tethys","kirograph-inspired","feature"],"design":null,"acceptance_criteria":"- [ ] save_snapshot, list_snapshots, diff_snapshot API methods\n- [ ] CLI subcommands snapshot save / list / diff\n- [ ] Snapshot file format documented (JSON schema in module-level docs)\n- [ ] Diff returns added_symbols, removed_symbols, added_edges, removed_edges\n- [ ] Default label is timestamp; named labels supported\n- [ ] Round-trip test: save → diff against unchanged graph returns zero changes","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-j9bu","dep_type":"parent-child"}],"created_at":"2026-05-10T04:31:32.968546400Z","updated_at":"2026-05-10T04:31:32.968546400Z","closed_at":null}
+{"id":"rivets-6jxv","title":"Extract Tethys::crate_root_for_file helper to dedupe triple-duplicated derivation","description":"The per-file crate_root derivation introduced by rivets-6aoc is now duplicated verbatim across three production sites in tethys. Each duplicate carries the same get_crate_for_file + src_root + file-parent-fallback logic. Extracting to a single helper on Tethys would make each call site a one-liner and ensure the three sites stay in sync.","status":"closed","priority":4,"issue_type":"task","assignee":null,"labels":[],"design":"## Current duplication\n\nThe same ~8-line pattern lives in three places:\n\n1. `crates/tethys/src/resolve.rs::resolve_refs_for_file` (Pass-2-imports)\n2. `crates/tethys/src/indexing.rs::compute_dependencies` (dep-graph from extracted refs)\n3. `crates/tethys/src/indexing.rs::compute_dependencies_from_stored` (streaming-mode dep graph)\n\nEach site has:\n\n```rust\nlet crate_root = if let Some(crate_info) =\n crate::cargo::get_crate_for_file(, &self.crates) // or self.crates()\n{\n crate_info.src_root()\n} else {\n debug!(file = %.display(), \"...; using file parent as sentinel crate_root\");\n .parent()\n .map_or_else(|| self.workspace_root.clone(), Path::to_path_buf)\n};\n```\n\n## Proposed helper\n\n```rust\nimpl Tethys {\n /// Returns the per-file crate_root for use by the resolver and dep-graph\n /// computation. Falls back to the file's parent directory as a sentinel\n /// when the file is outside any known crate (workspace-root example/bench\n /// dirs).\n fn crate_root_for_file(&self, file: &Path, caller: &'static str) -> PathBuf {\n if let Some(crate_info) = crate::cargo::get_crate_for_file(file, &self.crates) {\n crate_info.src_root()\n } else {\n debug!(\n operation = caller,\n file = %file.display(),\n \"File not in any known crate; using file parent as sentinel crate_root\"\n );\n file.parent()\n .map_or_else(|| self.workspace_root.clone(), Path::to_path_buf)\n }\n }\n}\n```\n\nEach call site collapses to:\n\n```rust\nlet crate_root = self.crate_root_for_file(, \"compute_dependencies\");\n```\n\n## Discovered during\n\nPR #63 round-2 claude review (post-merge of round-1 fixes). The reviewer flagged the duplication as a suggestion. Filing per the new gilfoyle tracker discipline (any deferral or 'follow-up' phrase needs a tracker entry filed at write-time).\n\n## Why P4\n\nCosmetic. Three sites are correctly in sync today. The risk is future drift if someone modifies one without the others. The helper is also a natural extension point for the rivets-bjdn BTreeMap optimization (the pre-computed lookup map would live in this helper).","acceptance_criteria":"- [ ] Tethys::crate_root_for_file helper extracted with consistent caller-name parameter\n- [ ] resolve.rs::resolve_refs_for_file uses the helper\n- [ ] indexing.rs::compute_dependencies uses the helper\n- [ ] indexing.rs::compute_dependencies_from_stored uses the helper\n- [ ] All resolver tests still pass (605+ in PR #63)\n- [ ] cargo clippy + cargo fmt clean","notes":"Closed: Fixed in PR #71 (commit f0220d0). Extracted Tethys::src_root_for_file helper, deduped 3 call sites in resolve.rs and indexing.rs. Helper named src_root_for_file (not crate_root_for_file as the issue text suggested) per cross-validation from 3 independent reviewers — matches CrateInfo::src_root() and avoids visual collision with the pre-existing Tethys::get_crate_root_for_file. ResolveContext::crate_root also renamed to src_root for end-to-end consistency. Added unit test for orphan-file fallback branch.","external_ref":null,"dependencies":[{"depends_on_id":"rivets-i8qn","dep_type":"blocks"},{"depends_on_id":"rivets-ycaq","dep_type":"parent-child"}],"created_at":"2026-05-13T00:28:31.586298200Z","updated_at":"2026-05-18T18:51:03.322639100Z","closed_at":"2026-05-18T18:51:03.322636200Z"}
+{"id":"rivets-2wp","title":"Create architecture document and diagrams for rivets","description":"Based on research from JSONL library design and project structure planning, create comprehensive architecture documentation with diagrams showing component relationships, data flow, and system design.","status":"closed","priority":2,"issue_type":"task","assignee":null,"labels":[],"design":"Create comprehensive documentation covering:\n\n**1. High-Level System Architecture**:\n- System architecture diagram showing all components/crates\n- Component interaction diagrams (crate dependency graph)\n- Key design decisions and rationale\n- Technology choices (libraries, patterns, etc.)\n- Future extensibility considerations\n\n**2. Data Flow**:\n- JSONL data flow through system (CLI → RPC → Storage → JSONL)\n- How JSONL operations work across components\n- Module organization within each crate\n\n**3. Storage Layer**:\n- Database schema diagram\n- Table relationships\n- Index strategy\n- Migration system\n\n**4. ID Generation System**:\n- Hash algorithm explanation\n- Collision handling strategy\n- Hierarchical ID format\n\n**5. Dependency System**:\n- 4 dependency types explained\n- Cycle detection algorithm\n- Ready work calculation logic\n\n**6. JSONL Sync**:\n- Export flow diagram\n- Import flow diagram\n- Conflict resolution strategy\n\n**7. RPC Protocol**:\n- Message format specification\n- Operation list\n- Error handling approach\n\nUse mermaid diagrams for visual clarity throughout. Format as ADR or similar structured format.","acceptance_criteria":"- Architecture document created at docs/architecture.md\n- System architecture diagram showing all crates and components\n- Crate dependency graph with clear separation of concerns\n- Data flow diagram showing JSONL operations (CLI → RPC → Storage → JSONL)\n- Database schema diagram with table relationships\n- ID generation algorithm documented with collision handling\n- Dependency system explained (4 types, cycle detection, ready work)\n- JSONL sync flows documented (export/import with conflict resolution)\n- RPC protocol specification (message format, operations, errors)\n- All diagrams in mermaid format (maintainable and version-controllable)\n- Design decisions justified with rationale\n- Future extensibility considerations noted\n- Document integrates findings from rivets-fk9 and rivets-kr3 research","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-fk9","dep_type":"blocks"},{"depends_on_id":"rivets-kr3","dep_type":"blocks"}],"created_at":"2025-11-17T21:08:45.698527855Z","updated_at":"2025-11-28T00:44:22.287918117Z","closed_at":"2025-11-28T00:44:22.287918117Z"}
+{"id":"rivets-53zq","title":"Test polish for workspace-crate resolver (PR #62 follow-up)","description":"Three low-priority test polish items surfaced in PR #62 reviews 4 and 5. None are correctness issues; PR #62 was merged with the core fix and the items below tracked here.","status":"open","priority":4,"issue_type":"task","assignee":null,"labels":[],"design":"## Items\n\n### F1 — Hoist `use crate::types::CrateInfo;` to mod tests scope\n\n`use crate::types::CrateInfo;` appears inline inside multiple test functions in `crates/tethys/src/resolver.rs::tests` (e.g., `resolves_workspace_crate_via_new_arm`, `single_segment_falls_back_to_bin_when_lib_path_absent`, `single_segment_returns_none_when_no_entry_point`). The `workspace_with_crates` helper in the same module references it too. Hoisting to a single `use` at the top of `mod tests` would clean up the duplication.\n\nTrivial; ~5 lines removed.\n\n### F2 — Test that lib_path takes priority when both lib_path and bin_paths are set\n\nThe single-segment workspace-crate arm uses:\n\n```rust\ntarget.lib_path\n .as_ref()\n .or_else(|| target.bin_paths.first().map(|(_, p)| p))\n```\n\nCurrently tested:\n- `lib_path: Some(...), bin_paths: []` (single_segment_workspace_crate_resolves_to_entry_point_file)\n- `lib_path: None, bin_paths: [bin]` (single_segment_falls_back_to_bin_when_lib_path_absent)\n- `lib_path: None, bin_paths: []` (single_segment_returns_none_when_no_entry_point)\n\nNOT directly tested: `lib_path: Some(lib), bin_paths: [bin]` should resolve to lib_path, not the bin. The `or_else` ordering implies this but it isn't locked down.\n\nAdd a test constructing a `CrateInfo` with both populated, asserting the resolved path ends with the lib_path file.\n\n### F3 — Prefix-of-crate-name negative test\n\nAsserts that a use-path head that is a strict prefix of a workspace crate name (e.g., `riv` when the workspace has `rivets`) does NOT match. The current `find(|c| c.name.replace('-', \"_\") == head)` uses `==` not `starts_with`, so this is correct by inspection — but an explicit test documents the intentional non-match for future readers.\n\nLow priority; documents semantics already obvious from the code.\n\n## Why one issue, not three\n\nThese are all small (10-line tests or smaller) and all touch the same test module in `resolver.rs`. A single follow-up PR addressing all three is cleaner than three separate ones.","acceptance_criteria":"- [ ] F1: `use crate::types::CrateInfo;` hoisted to mod tests scope; duplicate inline imports removed\n- [ ] F2: New test constructing CrateInfo with both lib_path and bin_paths populated; asserts lib_path wins\n- [ ] F3: New test asserting a use-path head that is a prefix of a workspace crate name returns None\n- [ ] cargo nextest run -p tethys passes\n- [ ] cargo clippy + cargo fmt clean","notes":null,"external_ref":null,"dependencies":[],"created_at":"2026-05-12T21:57:36.396522300Z","updated_at":"2026-05-12T21:57:36.396522300Z","closed_at":null}
{"id":"rivets-0dw","title":"Add debug logging to find_database() for observability","description":"The `find_database()` function has fallback logic but doesn't log which database was selected. This makes debugging harder when users have unexpected database files in `.rivets/`.","status":"open","priority":4,"issue_type":"chore","assignee":null,"labels":["logging","observability"],"design":"Add `tracing::debug!` calls to show which path was taken:\n\n```rust\nfn find_database(rivets_dir: &Path) -> Result {\n let standard = rivets_dir.join(\"issues.jsonl\");\n if standard.exists() {\n tracing::debug!(\"Using standard database: {}\", standard.display());\n return Ok(standard);\n }\n\n // ... search logic ...\n\n match found.len() {\n 0 => {\n tracing::debug!(\"No database found, using default: {}\", standard.display());\n Ok(standard)\n }\n 1 => {\n let path = found.into_iter().next().expect(\"checked len\");\n tracing::debug!(\"Using discovered database: {}\", path.display());\n Ok(path)\n }\n _ => {\n tracing::warn!(\"Multiple databases found: {:?}\", found);\n Err(...)\n }\n }\n}\n```\n\n**Location:** `context.rs:276-314`","acceptance_criteria":"- [ ] Debug log when using standard `issues.jsonl` path\n- [ ] Debug log when using default path for new workspace\n- [ ] Debug log when using discovered non-standard database\n- [ ] Warn log before returning error for multiple databases","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-5hvt","dep_type":"parent-child"}],"created_at":"2025-11-29T04:33:25.311491982Z","updated_at":"2025-11-29T04:33:25.311491982Z","closed_at":null}
-{"id":"rivets-dvsw","title":"Dead-code finder: symbols with zero incoming references","description":"Find unexported / non-public symbols with no incoming references — candidates for removal. Trivial to implement on the existing schema; high developer-value signal.\n\n**Inspired by:** KiroGraph's kirograph_dead_code tool and CLI command.\n\n**Implementation:**\nSingle SQL query:\n SELECT s.* FROM symbols s\n LEFT JOIN call_edges c ON c.callee_symbol_id = s.id\n LEFT JOIN refs r ON r.symbol_id = s.id\n WHERE c.callee_symbol_id IS NULL\n AND r.symbol_id IS NULL\n AND s.visibility != 'public'\n LIMIT ?;\n\nFiltering to non-public is important — public/exported symbols may be used by consumers outside the indexed workspace, so reporting them as dead would generate false positives.\n\n**Public API:**\n- Tethys::find_dead_code(limit: usize) -> Result>\n\n**CLI:**\n- tethys dead-code [--limit N] [--json]\n\n**Caveats to document:**\n- Macro-expanded references aren't tracked, so macro-only call sites can produce false positives.\n- Trait-method dispatch through dyn Trait may not appear as a direct call edge; investigate before reporting.","status":"open","priority":3,"issue_type":"feature","assignee":null,"labels":["tethys","kirograph-inspired","feature"],"design":null,"acceptance_criteria":"- [ ] find_dead_code() API method\n- [ ] CLI subcommand tethys dead-code with --limit and --json\n- [ ] Filters out public/exported symbols\n- [ ] Documents known false-positive sources in module docs\n- [ ] MCP tool tethys_dead_code (sibling rivets-o4re)\n- [ ] Tests on a fixture with both clearly-dead and clearly-live symbols","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-j9bu","dep_type":"parent-child"}],"created_at":"2026-05-10T04:33:07.744576100Z","updated_at":"2026-05-10T04:33:07.744576100Z","closed_at":null}
-{"id":"rivets-cxq","title":"Add priority constants (M-DOCUMENTED-MAGIC)","description":"Priority validation in domain/mod.rs uses magic number `4` without named constants, violating the M-DOCUMENTED-MAGIC guideline.\n\nLocations:\n- Line 133: `if self.priority > 4`\n- Line 269: `if self.priority > 4`\n- Line 500-512: Test using raw numbers 0..=4\n\nCurrent Issues:\n- Magic number repeated in multiple places\n- No single source of truth for valid priority range\n- Unclear to readers what the valid range is","status":"closed","priority":2,"issue_type":"task","assignee":null,"labels":["code-quality","documentation","refactoring"],"design":"Add public constants in domain/mod.rs after MAX_TITLE_LENGTH (line 207):\n\n```rust\n/// Minimum priority value (highest urgency)\npub const MIN_PRIORITY: u8 = 0;\n\n/// Maximum priority value (lowest urgency)\npub const MAX_PRIORITY: u8 = 4;\n```\n\nUpdate validation code:\n\n```rust\nif self.priority > MAX_PRIORITY {\n return Err(format!(\n \"Priority must be in range {}-{} (got {})\",\n MIN_PRIORITY, MAX_PRIORITY, self.priority\n ));\n}\n```\n\nUpdate tests to use constants.","acceptance_criteria":"- [ ] MIN_PRIORITY and MAX_PRIORITY constants defined\n- [ ] All validation code uses constants instead of magic numbers\n- [ ] Error messages reference constants\n- [ ] Tests use constants (e.g., `for priority in MIN_PRIORITY..=MAX_PRIORITY`)\n- [ ] Constants are documented\n- [ ] All tests pass","notes":null,"external_ref":null,"dependencies":[],"created_at":"2025-11-27T22:48:58.565187568Z","updated_at":"2025-11-28T15:00:31.345213766Z","closed_at":"2025-11-28T15:00:31.345213766Z"}
-{"id":"rivets-dr0b","title":"Refactor: Add structured logging per M-LOG-STRUCTURED","description":"The code uses raw println!/eprintln! instead of structured logging:\n\n```rust\neprintln!(\"Warning: Failed to reload after save error: {}\", reload_err);\n```\n\nPer M-LOG-STRUCTURED guideline, should use a logging crate with named properties:\n\n```rust\ntracing::warn!(\n name: \"storage.reload.failed\",\n error = %reload_err,\n \"Failed to reload after save error: {{error}}\"\n);\n```\n\n**Scope:** Review all eprintln! calls in execute.rs and convert to structured tracing events.","status":"closed","priority":3,"issue_type":"task","assignee":null,"labels":["M-LOG-STRUCTURED","execute.rs","refactor"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-q82","dep_type":"parent-child"}],"created_at":"2025-12-28T15:37:40.909394132Z","updated_at":"2025-12-28T17:26:55.645651081Z","closed_at":"2025-12-28T17:26:55.645651081Z"}
-{"id":"rivets-0jv","title":"Consider path canonicalization in find_rivets_root for symlink support","description":"The find_rivets_root function traverses parent directories to find a .rivets directory. If the start path contains symlinks, this might behave unexpectedly since it operates on the logical path rather than the resolved physical path.","status":"open","priority":4,"issue_type":"task","assignee":null,"labels":["enhancement","filesystem"],"design":"Current implementation:\\n```rust\\nlet mut current = start_dir.to_path_buf();\\n```\\n\\nPotential enhancement:\\n```rust\\nlet mut current = start_dir.canonicalize().ok()?.to_path_buf();\\n```\\n\\nTrade-offs:\\n- Pro: Correctly handles symlinked directories\\n- Con: canonicalize() fails if path doesn't exist\\n- Con: Adds complexity for edge case\\n\\nOptions:\\n1. Try canonicalize, fall back to original path on failure\\n2. Document current behavior as intentional\\n3. Add optional `follow_symlinks` parameter\\n\\nCurrent implementation is reasonable for most use cases. This is a low-priority enhancement for symlink-heavy workflows.","acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-5hvt","dep_type":"parent-child"}],"created_at":"2025-11-30T02:54:20.690844991Z","updated_at":"2025-11-30T02:54:20.690844991Z","closed_at":null}
-{"id":"rivets-itez","title":"C# parser: detect unsafe modifier and extract generics","description":"Minor metadata gaps in C# function parsing:\n\n1. `csharp.rs:981` - `is_unsafe` is hardcoded to `false`, should detect `unsafe` modifier\n2. `csharp.rs:983` - `generics` is always `None`, should extract type parameters\n\nThese are minor enhancements for completeness of C# symbol metadata.","status":"open","priority":4,"issue_type":"feature","assignee":null,"labels":["csharp-support","tethys"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-j9bu","dep_type":"parent-child"}],"created_at":"2026-01-30T00:18:38.853313385Z","updated_at":"2026-01-30T00:18:38.853313385Z","closed_at":null}
-{"id":"rivets-j5e7","title":"Feature: Implement --no-assignee flag for explicit unassignment","description":"In args.rs (lines 149-156), there's a TODO for implementing a --no-assignee flag:\n\n```rust\n/// New assignee\n///\n/// Note: To unassign, use `--no-assignee` flag instead. Clap does not\n/// support empty strings (\"\") as argument values by default.\n///\n/// TODO: Implement --no-assignee flag for explicit unassignment\n#[arg(short, long)]\npub assignee: Option,\n```\n\n**Problem:** Users cannot unassign an issue via the CLI because clap doesn't accept empty strings.\n\n**Solution:** Add a `--no-assignee` boolean flag that explicitly clears the assignee field when updating an issue.\n\n**Implementation notes:**\n- Add `#[arg(long, conflicts_with = \"assignee\")]` for the new flag\n- In execute_update, check if no_assignee is true and set assignee to Some(None) to clear it","status":"closed","priority":3,"issue_type":"task","assignee":null,"labels":["args.rs","cli","enhancement"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-q82","dep_type":"parent-child"}],"created_at":"2025-12-28T15:42:13.371513935Z","updated_at":"2025-12-28T21:37:19.178243668Z","closed_at":"2025-12-28T21:37:19.178243668Z"}
-{"id":"rivets-fn7","title":"Consider case-insensitive assignee matching","description":"The current assignee filtering is case-sensitive (\"alice\" does not match \"Alice\"). Consider whether case-insensitive matching would be more user-friendly.\n\nLocation: Documented in test at `crates/rivets-mcp/tests/integration.rs` line 1863-1869\n\nOptions to consider:\n1. Make assignee matching case-insensitive (more user-friendly)\n2. Keep case-sensitive but normalize on input (e.g., lowercase all assignees)\n3. Keep current behavior (explicit, predictable)","status":"open","priority":4,"issue_type":"task","assignee":null,"labels":["pr-feedback","ux"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-5hvt","dep_type":"parent-child"}],"created_at":"2025-11-30T18:29:37.222874542Z","updated_at":"2025-11-30T18:29:37.222874542Z","closed_at":null}
-{"id":"rivets-kuf","title":"Create comprehensive examples","description":"Create examples demonstrating all major rivets-jsonl features.\n\nExamples should be runnable and well-documented.","status":"open","priority":2,"issue_type":"task","assignee":null,"labels":[],"design":"Create in examples/:\n\n1. **basic.rs** - Simple read/write\n2. **streaming.rs** - Large file streaming\n3. **resilient.rs** - Resilient loading with warnings\n4. **query.rs** - Filtering and querying\n5. **atomic_write.rs** - Atomic writes\n6. **integration.rs** - Integration with rivets\n\nEach example should:\n- Be fully runnable with `cargo run --example `\n- Include inline documentation\n- Demonstrate best practices\n- Show error handling","acceptance_criteria":"- All 6 examples created\n- Each example compiles and runs\n- Examples well-documented\n- README lists all examples with descriptions\n- Examples demonstrate key features","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-dhs","dep_type":"blocks"},{"depends_on_id":"rivets-sx8j","dep_type":"parent-child"},{"depends_on_id":"rivets-t0k","dep_type":"blocks"}],"created_at":"2025-11-27T23:17:58.239786888Z","updated_at":"2025-11-27T23:17:58.239786888Z","closed_at":null}
-{"id":"rivets-8yl","title":"Implement resilient streaming for JsonlReader","description":"Implement stream_resilient() method that continues reading despite malformed JSON lines, collecting warnings instead of returning errors.\n\nThis provides the same resilient loading behavior as in_memory::load_from_jsonl().","status":"closed","priority":1,"issue_type":"task","assignee":null,"labels":[],"design":"```rust\nimpl JsonlReader {\n pub fn stream_resilient(\n self\n ) -> (impl Stream, WarningCollector)\n where\n T: DeserializeOwned + 'static,\n {\n let collector = WarningCollector::new();\n let collector_clone = collector.clone();\n \n let stream = futures::stream::unfold(\n (self, collector_clone),\n |(mut reader, collector)| async move {\n loop {\n match reader.read_line().await {\n Ok(Some(value)) => return Some((value, (reader, collector))),\n Ok(None) => return None, // EOF\n Err(e) => {\n // Collect warning and continue\n collector.add(Warning::MalformedJson {\n line_number: reader.line_number,\n error: e.to_string(),\n });\n // Continue to next line\n continue;\n }\n }\n }\n },\n );\n \n (stream, collector)\n }\n}\n```","acceptance_criteria":"- stream_resilient() method implemented\n- Returns (Stream, WarningCollector)\n- Continues reading on malformed JSON\n- Warnings collected for each error\n- Stream yields only successfully parsed records\n- Unit tests verify resilient behavior\n- Integration test with mixed valid/invalid JSONL","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-dgt","dep_type":"blocks"}],"created_at":"2025-11-27T23:15:55.303102489Z","updated_at":"2025-11-28T02:05:48.560873847Z","closed_at":"2025-11-28T02:05:48.560873847Z"}
-{"id":"rivets-cux","title":"Add interactive prompt tests with mock stdin","description":"Add tests for interactive prompts in execute.rs by mocking stdin input. This ensures the interactive title/description prompts handle various inputs correctly including:\n- Valid inputs\n- Invalid inputs that should be rejected\n- Empty inputs\n- Multiline inputs for descriptions","status":"open","priority":3,"issue_type":"task","assignee":null,"labels":["pr-feedback","testing"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-p9oz","dep_type":"parent-child"}],"created_at":"2025-11-30T04:11:25.383605039Z","updated_at":"2025-11-30T04:11:25.383605039Z","closed_at":null}
-{"id":"rivets-sx8j","title":"Rivets Core: Features & Refactoring","description":"Core rivets functionality: query system, filtering, config, daemon, storage backends, structured errors, and codebase refactoring.","status":"open","priority":2,"issue_type":"epic","assignee":null,"labels":[],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[],"created_at":"2026-02-21T02:28:28.807289106Z","updated_at":"2026-02-21T02:28:28.807289106Z","closed_at":null}
-{"id":"rivets-n3w","title":"Add smart suggestions after operations","description":"Show context-aware suggestions after operations:\n\n```\n$ rivets close rivets-abc\nClosed issue: rivets-abc\n\nTip: rivets-def is now unblocked. Run 'rivets ready' to see available work.\n```\n\nAlso show warnings for risky operations:\n```\n$ rivets update rivets-abc --status closed\nWarning: Use 'rivets close' instead for proper close workflow.\nContinue anyway? [y/N]:\n```","status":"open","priority":3,"issue_type":"feature","assignee":null,"labels":["phase-2","ux"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-6bc","dep_type":"parent-child"}],"created_at":"2025-11-30T18:36:24.182937809Z","updated_at":"2025-11-30T18:36:24.182937809Z","closed_at":null}
-{"id":"rivets-40p","title":"Add NO_COLOR environment variable support","description":"Support NO_COLOR environment variable per https://no-color.org/\n\nWhen NO_COLOR is set (to any value), disable all colored output. This ensures compatibility with:\n- CI/CD pipelines\n- Log aggregation systems\n- Users with accessibility needs\n- Piped output\n\nAlso auto-detect if stdout is a TTY and disable colors if not.","status":"open","priority":2,"issue_type":"task","assignee":null,"labels":["accessibility","phase-1a","ux"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-6bc","dep_type":"parent-child"}],"created_at":"2025-11-30T18:34:58.392325579Z","updated_at":"2025-11-30T18:34:58.392325579Z","closed_at":null}
+{"id":"rivets-dn35","title":"tethys: Pass 2 short-circuits on import-less files, leaving workspace-internal refs unresolved","description":"crates/tethys/src/resolve.rs::resolve_refs_for_file returns early when imports.is_empty(), so any unresolved reference in a file with no use statements never reaches Pass-2 import/fallback resolution.\n\nCLAUDE.md flags this explicitly in the 'Tethys resolver internals' section but it's not tracked.\n\nAffected input shape: files that legitimately reference workspace symbols without a use statement — typically:\n - test files using fully-qualified paths (crate_name::Type)\n - mod.rs files that re-export via pub use only\n - integration test harnesses\n\nImpact on resolution coverage: unknown without measurement. The early-return is a speed optimization that traded correctness for cycles; the import-less case may have been judged rare when it shipped, but on the rivets workspace itself it's worth quantifying.\n\nReproduction: index the rivets workspace, then query for refs where r.symbol_id IS NULL AND r.file_id IN (SELECT id FROM files WHERE NOT EXISTS (SELECT 1 FROM imports WHERE file_id = files.id)) AND r.reference_name IN (SELECT name FROM symbols). Any non-zero result is a Pass-2 miss this short-circuit caused.\n\nLikely fix: drop the imports.is_empty() short-circuit entirely, or replace with a per-ref check (don't skip the whole file, just skip refs whose name can't resolve without an import context). Pass 2's fallback search_symbol_by_name path doesn't actually need imports — only the import-resolution path does.","status":"closed","priority":3,"issue_type":"bug","assignee":null,"labels":[],"design":null,"acceptance_criteria":"- [ ] Probe SQL above returns 0 rows on the rivets workspace\n- [ ] Regression test: a fixture file with no use statements but a fully-qualified workspace reference (e.g., crate_target::Widget) resolves correctly\n- [ ] resolve_refs_for_file no longer returns Ok(()) early when imports.is_empty()\n- [ ] No regression in resolution time on the rivets workspace (measure via tethys index timing)","notes":"Closed: Fixed in PR #69 (commit 21e82e6). Removed the imports.is_empty() short-circuit in resolve_refs_for_file so import-less files now reach fallback_symbol_search. Locked in by tests/pass2_no_imports.rs. Acceptance criterion #2 (qualified-ref test) was partially met: the unqualified-fallback case works; the qualified-path case is a separate gap filed as rivets-044i.","external_ref":null,"dependencies":[{"depends_on_id":"rivets-ycaq","dep_type":"parent-child"}],"created_at":"2026-05-13T02:48:03.363815600Z","updated_at":"2026-05-18T18:51:03.202003600Z","closed_at":"2026-05-18T18:51:03.202000400Z"}
+{"id":"rivets-jwf9","title":"C# namespace resolution for using statements","description":"`resolve_import()` in `csharp.rs:109-111` returns empty vec, meaning C# `using` statements don't resolve to files.\n\nThis was marked \"Task 6\" in the original TODO, suggesting it was planned. Need to implement namespace-to-file resolution for C# projects.","status":"open","priority":3,"issue_type":"feature","assignee":null,"labels":["csharp-support","tethys"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-j9bu","dep_type":"parent-child"}],"created_at":"2026-01-30T00:18:33.059266108Z","updated_at":"2026-01-30T00:18:33.059266108Z","closed_at":null}
+{"id":"rivets-4q2","title":"Integrate resilient JSONL loading with in_memory storage","description":"Update rivets in_memory::load_from_jsonl() to use the new rivets-jsonl library's resilient loading functionality.\n\nThis replaces the current manual JSONL parsing with the library implementation.","status":"closed","priority":1,"issue_type":"task","assignee":null,"labels":[],"design":"Update in_memory.rs to use rivets-jsonl:\n\n```rust\nuse rivets_jsonl::read_jsonl_resilient;\n\npub async fn load_from_jsonl(\n path: &Path,\n prefix: String,\n) -> Result<(Box, Vec)> {\n let (issues, warnings) = read_jsonl_resilient::(path).await?;\n \n // Convert rivets_jsonl::Warning to LoadWarning\n let load_warnings: Vec = warnings\n .into_iter()\n .map(|w| match w {\n rivets_jsonl::Warning::MalformedJson { line_number, error } => {\n LoadWarning::MalformedJson { line_number, error }\n }\n rivets_jsonl::Warning::SkippedLine { line_number, reason } => {\n // Map to appropriate LoadWarning variant\n LoadWarning::MalformedJson { line_number, error: reason }\n }\n })\n .collect();\n \n // Continue with existing dependency reconstruction logic\n let storage = Arc::new(Mutex::new(InMemoryStorageInner::new(prefix)));\n // ... existing code\n \n Ok((Box::new(storage), load_warnings))\n}\n```\n\nAdd rivets-jsonl as dependency in rivets/Cargo.toml.","acceptance_criteria":"- in_memory::load_from_jsonl uses rivets-jsonl library\n- Warnings correctly mapped between types\n- All existing tests still pass\n- No performance regression\n- Dependency added to Cargo.toml","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-uyg","dep_type":"blocks"}],"created_at":"2025-11-27T23:16:06.671886113Z","updated_at":"2025-11-28T07:04:14.251308562Z","closed_at":"2025-11-28T07:04:14.251308562Z"}
+{"id":"rivets-1zz","title":"Add conflict logging for Automerge merges","description":"Even though CRDTs auto-resolve conflicts, users should be aware when merges happen.\n\nImplement:\n- Log when merge() is called and changes are integrated\n- Track which fields had concurrent modifications\n- Optional: Store merge events in a separate log file or document metadata\n- Surface merge history in `rv log` or similar command","status":"closed","priority":2,"issue_type":"task","assignee":null,"labels":[],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-5vz","dep_type":"blocks"}],"created_at":"2025-11-30T21:49:37.307979170Z","updated_at":"2025-12-23T04:45:28.797678904Z","closed_at":"2025-12-23T04:45:28.797678904Z"}
+{"id":"rivets-4l2","title":"Implement init command and workspace setup","description":"Implement the init command that creates the .rivets directory structure, initializes the SQLite database, and sets up configuration.","status":"closed","priority":1,"issue_type":"task","assignee":"rust-developer","labels":[],"design":"Based on MVP architecture (in-memory + JSONL):\n\n**Directory Structure**:\n```\n.rivets/\n├── issues.jsonl # JSONL data file\n├── config.yaml # Project config\n└── .gitignore # Ignore metadata files\n```\n\n**Init Process**:\n1. Check if already initialized (error if exists)\n2. Create .rivets/ directory\n3. Create default config.yaml with storage backend settings\n4. Set issue prefix (from flag or prompt)\n5. Create .gitignore\n6. Create empty issues.jsonl file\n\n**Git Integration**:\n- Add .rivets to .gitignore root if not present\n- Suggest git commit for initial setup\n\n**CLI**:\n```bash\nrivets init [--prefix ] [--quiet]\n```\n\n**Config Template**:\n```yaml\nissue-prefix: \"proj\"\nstorage:\n backend: \"memory\"\n data_file: \".rivets/issues.jsonl\"\n```","acceptance_criteria":"- Init creates .rivets directory\n- config.yaml created with storage backend = memory\n- issues.jsonl created (empty initially)\n- .gitignore created\n- Prefix validation (alphanumeric, 2-20 chars)\n- Error if already initialized\n- Integration test verifies complete setup\n- No database files created","notes":null,"external_ref":null,"dependencies":[],"created_at":"2025-11-17T22:16:47.684395549Z","updated_at":"2025-11-30T01:41:23.084110706Z","closed_at":"2025-11-30T01:41:23.084110706Z"}
+{"id":"rivets-6pi","title":"Implement JsonlWriter::write() and write_all() methods","description":"Implement write methods for JsonlWriter that serialize and write records to the output stream.\n\nIncludes both single-record write() and batch write_all() for efficiency.","status":"closed","priority":1,"issue_type":"task","assignee":null,"labels":[],"design":"```rust\nuse serde::Serialize;\nuse tokio::io::AsyncWriteExt;\n\nimpl JsonlWriter {\n pub async fn write(&mut self, value: &T) -> Result<()> {\n let json = serde_json::to_string(value)?;\n self.writer.write_all(json.as_bytes()).await?;\n self.writer.write_all(b\"\\n\").await?;\n Ok(())\n }\n \n pub async fn write_all(\n &mut self,\n values: impl IntoIterator,\n ) -> Result<()> {\n for value in values {\n self.write(&value).await?;\n }\n Ok(())\n }\n \n pub async fn flush(&mut self) -> Result<()> {\n self.writer.flush().await?;\n Ok(())\n }\n}\n```","acceptance_criteria":"- write() method serializes and writes single record\n- write_all() writes multiple records efficiently\n- flush() method flushes buffered data\n- Each record terminated with newline\n- Proper error handling for serialization failures\n- Unit tests verify writing single/multiple records\n- Integration test with read/write round-trip","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-uo7","dep_type":"blocks"}],"created_at":"2025-11-27T23:14:51.609289345Z","updated_at":"2025-11-28T00:21:22.277922663Z","closed_at":"2025-11-28T00:21:22.277922663Z"}
+{"id":"rivets-v465","title":"tethys: import resolver leaks legitimate cross-crate refs to unscoped fallback","description":"Discovered as the root-cause blocker for rivets-3d0s during gilfoyle/checkpointed-build (PR #61 follow-up branch fix/rivets-3d0s-stdlib-symbol-pollution).\n\n**Symptom:** ~80% of legitimate cross-crate refs in Cargo-dep-allowed pairs are being resolved through the unscoped workspace-wide fallback (search_unique_symbol_by_name) instead of via Pass 2's explicit/glob import resolution.\n\n**Quantified on the rivets workspace (post-rivets-0gom, pre-rivets-3d0s):**\n - Total cross-crate refs: 294\n - In FORBIDDEN pairs (phantoms): 174\n - In ALLOWED pairs (legitimate): 120\n - Of those 120 legit_cross, **95 reach unscoped fallback** (would be demoted by an extended rivets-3d0s-style audit)\n - Breakdown of leaks: ~50 function calls, ~48 method calls, ~6 enum_variant constructors\n\n**Why this is a bigger bug than rivets-3d0s:**\nSame code path (unscoped fallback) is currently doing BOTH legitimate cross-crate resolution (when imports leak) and phantom resolution (when no real import exists). Any audit/filter that tries to demote phantom resolutions at this level hits 80% of legitimate refs too. The rivets-3d0s audit-and-demote design is blocked on this — without fixing the import resolver, no kind-compatibility rule can distinguish the two populations.\n\n**Reproduction:**\nOn branch fix/rivets-3d0s-stdlib-symbol-pollution (or main, post rivets-0gom):\n 1. tethys index --rebuild (re-index rivets workspace)\n 2. uv run --no-project --python 3.13 -- python .rivets-3d0s/audit_simulation.py\n - With EXTENDED=True at the top of the script\n 3. Observe: 'legit-cross demoted: 95' — these are the leak signal.\n\n**Discovered during:** gilfoyle/checkpointed-build of slice 1+2 of rivets-3d0s. The audit landed; probe showed 174 -> 164 phantoms (5.7% reduction vs predicted 58.6%). Root cause analysis: the audit's narrowing of candidate sets converts previously-ambiguous names into unique matches, creating new method/function phantoms. Extending the audit to cover call->method via unscoped (Option A) was probed and shown to have an 80% false-positive rate on ALLOWED pairs. Slice 1+2 was reverted.","status":"closed","priority":2,"issue_type":"bug","assignee":null,"labels":[],"design":"**Investigation entry points:**\n\n1. **crates/tethys/src/resolve.rs::resolve_via_explicit_import** — does it correctly handle all of:\n - 'use rivets::storage::Storage;' (named import, multi-segment path)\n - 'use rivets::storage::*;' (glob import)\n - 'use rivets::storage;' followed by 'storage::Storage' (partial-path use)\n - Re-exports ('pub use crate::storage::Storage;' in lib.rs)\n - Alias imports ('use rivets::storage::Storage as S;')\n\n2. **crates/tethys/src/resolve.rs::resolve_via_glob_import** — same checks for glob.\n\n3. **crates/tethys/src/languages/rust.rs** — does the extractor capture imports correctly? Maybe imports are extracted but with wrong source_module strings.\n\n4. **The 'method' bucket specifically:** even with imports of the TYPE, method calls like 'storage.create_issue()' may bypass import resolution because tree-sitter doesn't know that 'storage's type came from an import. This may require special handling — possibly the right answer is 'don't try; let LSP handle it' (which routes back to rivets-714v).\n\n**Likely findings:**\n- The 50 function calls are probably failing on multi-segment paths or re-exports\n- The 48 method calls likely need type-info (LSP) — separately tracked as rivets-714v\n- The 6 enum_variant constructors likely need qualified-name handling (e.g., 'Error::Variant' resolving when only 'Error' is imported)\n\n**Suggested approach:**\nDo gilfoyle/prove-it-prototype on this issue. Build a probe that:\n 1. Lists the 95 leaked refs by (caller_file, ref_name, target_file)\n 2. For each, inspects the caller_file's imports to determine what SHOULD have matched\n 3. Classifies failures: 're-export', 'multi-segment-path', 'glob-import-miss', 'method-needs-type-info', 'qualified-name-construct'\n\nThen design fixes per category. Some may be cheap (re-export support); some may be expensive (type-info-via-LSP).","acceptance_criteria":"- [ ] Categorize the 95 (or current count) leaks by failure class (re-export miss, multi-segment, glob, method-needs-type-info, etc.)\n- [ ] For at least the top 2 failure classes, design + implement fixes\n- [ ] On the rivets workspace post-fix: legit_cross refs via unscoped drops by >= 50% (95 -> <= 47)\n- [ ] No regression in same-crate or import-based resolution counts\n- [ ] After this lands, re-attempt rivets-3d0s: the extended audit (Option A) should have a manageable false-positive rate (<= 10 ALLOWED-pair demotions)","notes":"\n\n## Checkpointed-build outcome (2026-05-12)\n\nBranch: fix/rivets-v465-import-resolver-leak. Status: slices 1+2 IMPLEMENTED; slice 3 verification done; ACTUAL impact much smaller than design predicted.\n\n**What slice 1+2 actually fixes:** the small-but-real case where a leaked cross-crate ref's bare name matches an explicit workspace-crate import (`use rivets::storage::in_memory::new_in_memory_storage` + bare `new_in_memory_storage()`). resolve_module_path now correctly handles `path[0] == workspace_crate_name`.\n\n**Empirical impact on rivets workspace:** 6 refs migrate from Pass-2-fallback to Pass-2-imports. Pre-fix all 279 cross-crate refs (105 ALLOWED + 174 FORBIDDEN) went through fallback; post-fix 273 still do.\n\n**Why so small:** the refining probe (.rivets-v465/refine_c5_upper_bound.py) revealed that only 5 of 105 ALLOWED-pair leaks have a `ref_name`-matching workspace import. The other 100 are method-on-imported-type calls (`use rivets::Storage; s.create_issue()`) — the type is imported but the method has no import of its own. Pass 2's name-matching resolver fundamentally cannot catch these without type information.\n\n**Re-scoped acceptance criteria:** original \"≥50% fallback reduction\" replaced with \"≥5 refs migrate\" (achievable; 6 measured). The dominant population (100 method-on-imported-type leaks + 174 FORBIDDEN phantoms) now depends on rivets-714v (LSP integration for multi-crate workspaces).\n\n**Diagnostic artifacts retained on the branch:**\n- .rivets-v465/probe.py - initial leak categorization\n- .rivets-v465/check_same_crate_ambiguity.py - same-crate-vs-cross split \n- .rivets-v465/check_pass_provenance.py - resolved ref counts\n- .rivets-v465/source_module_shapes.py - cheapest falsifier (workspace-crate imports shape)\n- .rivets-v465/refine_c5_upper_bound.py - design-correcting probe\n- .rivets-v465/after-fix-counts.txt - final empirical snapshot\n- .rivets-v465/falsifiable-design.md - includes \"Re-design rationale\" section explaining the C5/C6 revision\n\n**Implication for rivets-3d0s:** still blocked. The fallback still handles ~99% of cross-crate resolution post-fix, so any rule at the fallback level (like rivets-3d0s's audit-and-demote) would still produce massive false positives. Audit-and-demote becomes viable only after rivets-714v migrates the method-on-imported-type calls out of fallback.\n\n## Related-issues discovery (2026-05-12, after PR #62 opened)\n\nProcess miss: should have searched the rivets tracker before filing rivets-v465. Two open issues describe the same multi-crate-resolver code class from different angles:\n\n- **[rivets-6aoc](rivets-6aoc)** (P2, open): \"resolve.rs:66 uses `self.workspace_root.join('src')` which is wrong for multi-crate workspaces. Should use per-crate src_root from `self.crates: Vec`. Flagged in PR #55 review.\"\n- **[rivets-34tv](rivets-34tv)** (P2, open): \"Two sites in lib.rs (~1089 and ~1516) still hardcode workspace_root/src/ as crate root in `compute_dependencies` and `resolve_unresolved_references`.\"\n- **[rivets-m4wt](rivets-m4wt)** (P1, closed Feb): \"Parse Cargo.toml to detect actual crate root.\" `CrateInfo` discovery was added but applied incompletely — the three hardcoded sites above survived.\n\n**rivets-v465's relationship to these:** distinct bug, same code class. v465 fixes `path[0]==workspace_crate_name` matching (e.g., `use rivets::Foo`). 6aoc/34tv fix the caller's own `crate_root` being hardcoded (affects `use crate::Foo` resolution for non-root crates in multi-crate workspaces). Both are needed for full multi-crate support; neither subsumes the other.\n\n**Hardcoded `workspace_root.join(\"src\")` still present (as of HEAD of rivets-v465 branch):**\n- `crates/tethys/src/resolve.rs:66` (resolve_cross_file_references)\n- `crates/tethys/src/indexing.rs:857` (compute_dependencies first call site)\n- `crates/tethys/src/indexing.rs:1023` (compute_dependencies second call site)\n\nEmpirical implication: my v465 measurements showed 5971 same-crate refs resolved, but that count is masked by Pass-1 same-file resolution + Pass-2 fallback. Pass-2 cross-file same-crate refs (`use crate::Foo` where `crate` means the caller's actual crate, not `workspace_root/src`) are likely ALSO failing in non-root crates due to 6aoc/34tv. A combined fix would compound the value.\n\nClosed: Merged in PR #62 (commit c1af978)","external_ref":null,"dependencies":[],"created_at":"2026-05-12T04:18:20.703569200Z","updated_at":"2026-05-12T22:01:15.506283400Z","closed_at":"2026-05-12T22:01:15.506276900Z"}
+{"id":"rivets-t0k","title":"Add comprehensive tests for resilient loading","description":"Create comprehensive tests for Phase 2 resilient loading functionality.\n\nTests should verify warning collection, error recovery, and integration with in_memory storage.","status":"closed","priority":1,"issue_type":"task","assignee":null,"labels":[],"design":"Test categories:\n\n**Warning collection tests**:\n- Collect warnings for malformed JSON\n- Collect warnings for skipped lines\n- Warning contains correct line numbers\n- Multiple warnings collected\n\n**Resilient streaming tests**:\n- stream_resilient() continues on errors\n- stream_resilient() yields only valid records\n- Mixed valid/invalid records handled correctly\n\n**Integration tests**:\n- read_jsonl_resilient() with corrupted file\n- in_memory::load_from_jsonl with warnings\n- Verify existing in_memory tests still pass","acceptance_criteria":"- Unit tests for warning collection\n- Unit tests for stream_resilient()\n- Integration tests for read_jsonl_resilient()\n- Integration tests for in_memory integration\n- All tests pass\n- Test coverage >80% for Phase 2 code","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-4q2","dep_type":"blocks"}],"created_at":"2025-11-27T23:16:12.327924224Z","updated_at":"2025-11-28T07:16:42.034792534Z","closed_at":"2025-11-28T07:16:42.034792534Z"}
+{"id":"rivets-e7fp","title":"Implement content hashing for file change detection","description":"The content_hash field exists in the schema and types but is always passed as None. Implement actual content hashing to enable content-based change detection alongside mtime. This would allow skipping re-indexing when mtime changes but content has not. Three TODO sites: lib.rs (2) and batch_writer.rs (1).","status":"closed","priority":3,"issue_type":"feature","assignee":null,"labels":[],"design":null,"acceptance_criteria":null,"notes":"Closed: Duplicate of rivets-3l14 (older, describes the same content_hash TODOs in lib.rs and batch_writer.rs).","external_ref":null,"dependencies":[{"depends_on_id":"rivets-j9bu","dep_type":"parent-child"}],"created_at":"2026-02-05T22:54:56.057264980Z","updated_at":"2026-05-10T04:30:14.008782Z","closed_at":"2026-05-10T04:30:14.008780200Z"}
+{"id":"rivets-wsix","title":"tethys: audit other UPSERT-only tables for stale-row growth (sibling of rivets-lcb6)","description":"Surfaced by code-reviewer during PR #65 multi-agent review. The rivets-lcb6 fix establishes the pattern: UPSERT-only tables (INSERT ... ON CONFLICT DO UPDATE) need an explicit clear before full re-index, or 'ref_count'-style aggregates grow monotonically and removed-at-source rows persist as phantoms.\n\nPR #65 fixed file_deps (and noted call_edges already had its clear_all_call_edges). Other tables in crates/tethys/src/db/ using ON CONFLICT DO UPDATE were NOT audited.\n\nLikely candidates to grep (ON CONFLICT...DO UPDATE in crates/tethys/src/db/):\n- imports (db/imports.rs?)\n- refs (db/refs.rs)\n- attributes (db/attributes.rs?)\n- symbols' subordinate tables (enum variants, struct fields — added in PR #58)\n- architecture / coupling tables (added in PR #60)\n- any new table added after rivets-lcb6 lands\n\nFor each: classify as\n (a) UPSERT with monotonically-growing aggregate column → SAME BUG, needs clear_all_X + call from index_with_options\n (b) UPSERT but no growing aggregate → still has the 'stale row persists when source is deleted' subset of the bug\n (c) UPSERT but table is fully rebuilt from another source-of-truth each run → no bug\n\nDeliverable: a comment in this issue listing every UPSERT-only table with its classification, then file follow-up issues for any (a) or (b) cases.\n\nThe 30.7% baseline-vs-resolved gap noted in project_resolver_baseline_2026_05_12.md may have contributions from this class of bug we haven't measured.\n\nSource: PR #65 multi-agent review aggregate (code-reviewer 'Suggestion #3'/Strength).","status":"in_progress","priority":2,"issue_type":"bug","assignee":null,"labels":[],"design":null,"acceptance_criteria":"* All UPSERT-only tables in crates/tethys/src/db/ enumerated with classification (a/b/c)\n* Follow-up issues filed for each (a) or (b) classification\n* If any (a) is found, regression-fence test added matching the file_deps_idempotency.rs pattern","notes":null,"external_ref":null,"dependencies":[],"created_at":"2026-05-17T02:43:53.022009444Z","updated_at":"2026-05-19T01:31:35.201636100Z","closed_at":null}
+{"id":"rivets-dhxo","title":"tethys: streaming compute_all_dependencies re-inserts file_deps from orphan files (deleted-from-disk but still in DB)","description":"Streaming-mode indexing (IndexOptions::with_streaming()) calls compute_all_dependencies (crates/tethys/src/indexing.rs:943) which iterates self.db.list_all_files() — the FULL set of files in the DB, including files that have been deleted from disk since their last index. For each such orphan, it loads the stale stored imports + refs and calls compute_dependencies_from_stored, which re-inserts file_deps rows with the orphan''s file_id as from_file_id.\n\nDownstream queries (coupling Ce/Ca, callers, cycles, impact analysis) then see the orphan as a real source of cross-file edges, producing phantom contributions.\n\n**Asymmetries — which paths are affected:**\n\n| Path | Status |\n|---|---|\n| tethys index --rebuild (any mode) | Not affected — db.reset() wipes DB; orphans gone |\n| tethys index non-streaming (default) | Not affected — compute_dependencies runs per disk-file in the parse loop; orphans never seen |\n| tethys index streaming | **Affected** |\n\n**Root cause:** index_with_options has no orphan-cleanup pass. db/files.rs:145-146 only DELETEs symbols/imports when an EXISTING file is RE-indexed (still on disk). Files deleted from disk are never processed, so no DELETE fires for them. FileChange::Deleted is detected in reindex.rs:122 but only by get_stale_files() (observation API, not called from indexing).\n\n**How discovered:** Gemini code review on PR #65 flagged \"compute_all_dependencies may re-calculate dependencies for stale entries (files in DB but deleted from disk).\" Initial round-1 verdict was \"reject (not a real bug)\" on cascade-cleanup grounds; pressure-test revealed the cascade only applies to re-indexed files, not orphans. See .rivets-lcb6/review-decisions-round-1.md for the corrected verdict.\n\n**Not caused by rivets-lcb6.** Pre-existing in streaming mode. rivets-lcb6''s clear_all_file_deps even partially mitigates by wiping file_deps each run, but compute_all_dependencies immediately re-inserts from orphans.\n\n**Likely fix shape:** before compute_all_dependencies runs, delete file rows whose disk path doesn''t resolve (and let FK cascades clean up dependents). Either:\n1. New cleanup_orphan_files that classifies via the same staleness check as reindex.rs::classify_indexed_file and DELETEs orphan rows, called from index_with_options before resolver/dep passes.\n2. Filter compute_all_dependencies to skip files whose path doesn''t exist on disk (lighter touch, leaves orphan rows in files table).\n\nOption 1 is cleaner — once orphan files are gone from the DB, all downstream queries are correct without per-callsite filtering.","status":"open","priority":3,"issue_type":"bug","assignee":null,"labels":[],"design":null,"acceptance_criteria":"- [ ] Regression test: index a 2-file workspace in streaming mode, delete one file from disk, re-index (no --rebuild). Verify (a) the orphan no longer appears in list_all_files, OR (b) file_deps contains no rows with from_file_id == orphan's id.\n- [ ] Equivalent test for non-streaming mode confirms the bug doesn't manifest there (regression fence).\n- [ ] No perf regression: indexing a workspace with N files and 0 orphans takes the same time before and after.","notes":null,"external_ref":null,"dependencies":[],"created_at":"2026-05-13T03:26:07.176733800Z","updated_at":"2026-05-13T03:26:37.855664300Z","closed_at":null}
+{"id":"rivets-w0qw","title":"tethys: define and document --depth 0 semantics for impact analysis","description":"After PR #57, `tethys impact --depth N` is wired through. `--depth 0` is currently undefined behavior: it would call `get_transitive_dependents(file_id, Some(0))` which depends on whatever the SQLite recursive CTE does at depth 0 (likely returns nothing).\n\nDecide and document the contract: either treat 0 as 'direct dependents only and no transitive search', or reject it at the CLI layer with a friendly error. Add a test pinning the behavior either way.","status":"open","priority":3,"issue_type":"task","assignee":null,"labels":[],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[],"created_at":"2026-04-27T21:56:00.600464844Z","updated_at":"2026-04-27T21:56:00.600464844Z","closed_at":null}
+{"id":"rivets-zoi3","title":"tethys: expand file_deps test coverage (rename, target deletion, DB-unit, rebuild idempotency)","description":"Surfaced by pr-test-analyzer during multi-agent review of PR #65. The 2 regression tests added in rivets-lcb6 fence the specific reported bug well, but four adjacent code paths are uncovered.\n\nTests to add (in crates/tethys/tests/file_deps_idempotency.rs unless noted):\n\n1. file_deps_swapped_when_use_target_changes (rating 8 / high). Replace 'use crate_target::Widget' with 'use crate_target_alt::Gadget' in the fixture. Re-index. Assert the edge to crate_target is GONE and the edge to crate_target_alt EXISTS — check by exact FileId lookup, not just row count (counts could net to zero coincidentally and hide the bug).\n\n2. clear_all_file_deps unit test (rating 6 / medium). crates/tethys/src/db/file_deps.rs has no #[cfg(test)] mod tests block. clear_all_file_deps is pub and per CLAUDE.md 'every public method needs tests' (rust-best-practices Rule 39). Add: insert 2 deps via insert_file_dependency, call clear_all_file_deps, assert get_file_dependencies returns [] for both source FileIds. Catches SQL typos / schema renames the integration tests would miss.\n\n3. file_deps_target_file_deletion_removes_edge (rating 8 / high but DEFERRED to rivets-dhxo). Delete crate_target/src/lib.rs from disk, re-index, assert no edges reference the orphan file. This test would currently FAIL in streaming mode — that's the whole point. Defer landing this until rivets-dhxo is fixed so it doesn't break CI. Cross-reference here so the regression-fence test gets added alongside the fix.\n\n4. rebuild_with_options idempotency (rating 7 / medium). The design doc notes rebuild is 'correct by accident' because db.reset() wipes the table first. Add a #[case::rebuild] variant or separate test calling rebuild_with_options(IndexOptions::default()) twice and assert FileDepsSnapshot stability. Defends against someone 'optimizing' rebuild to skip the reset later.\n\n5. (Suggestion, rating 4) file with 2 uses of same target → remove one → row remains, ref_count drops. Probes the multi-ref-per-edge code path the existing tests don't exercise.\n\nSource: PR #65 multi-agent review aggregate (pr-test-analyzer findings 1-5).","status":"open","priority":2,"issue_type":"task","assignee":null,"labels":[],"design":null,"acceptance_criteria":"* file_deps_swapped_when_use_target_changes added and passing\n* clear_all_file_deps unit test added to db/file_deps.rs #[cfg(test)] mod tests\n* rebuild_with_options idempotency test added (as #[case::rebuild] or standalone)\n* (deferred) file_deps_target_file_deletion_removes_edge added once rivets-dhxo lands","notes":null,"external_ref":null,"dependencies":[],"created_at":"2026-05-17T02:43:32.040071906Z","updated_at":"2026-05-17T02:43:32.040071906Z","closed_at":null}
+{"id":"rivets-yip","title":"Write comprehensive API documentation","description":"Write comprehensive rustdoc documentation for all public APIs with usage examples.\n\nDocumentation should match or exceed the quality outlined in research document.","status":"open","priority":2,"issue_type":"task","assignee":null,"labels":[],"design":"Documentation requirements:\n\n1. **Module-level docs** (lib.rs):\n - Overview of library\n - Quick start guide\n - Feature comparison with other libraries\n - Performance characteristics\n\n2. **Type documentation**:\n - JsonlReader with examples\n - JsonlWriter with examples\n - JsonlQuery with examples\n - Warning types\n - Error types\n\n3. **Method documentation**:\n - All public methods documented\n - Include # Example blocks\n - Document edge cases\n - Document performance characteristics\n\n4. **README.md**:\n - Installation instructions\n - Quick start\n - Feature list\n - Performance benchmarks\n - Comparison with alternatives\n\nGenerate docs with `cargo doc --no-deps --open`.","acceptance_criteria":"- All public APIs documented\n- Module-level docs comprehensive\n- README.md complete\n- All doc examples compile (tested with cargo test --doc)\n- Documentation coverage >90%\n- cargo doc generates without warnings","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-6640","dep_type":"parent-child"},{"depends_on_id":"rivets-kuf","dep_type":"blocks"}],"created_at":"2025-11-27T23:18:03.937459711Z","updated_at":"2025-11-27T23:18:03.937459711Z","closed_at":null}
+{"id":"rivets-v21s","title":"Implement cycle detection for file dependencies","description":"Implement the `detect_cycles()` method that currently returns \"not implemented\".\n\n**What already exists:**\n- ✅ `FileGraphOps::detect_cycles()` trait method (graph/mod.rs:88)\n- ✅ `FileGraphOps::detect_cycles_involving(file_id)` trait method\n- ✅ `tethys cycles` CLI command (cli/cycles.rs)\n- ✅ `file_dependencies` table with all edges\n- ❌ Actual implementation (returns error at graph/sql.rs:318)\n\n**Implementation approach (~100 lines):**\n\nOption A: **Tarjan's SCC** (finds all cycles efficiently)\n```rust\nfn detect_cycles(&self) -> Result> {\n // Standard Tarjan's algorithm for strongly connected components\n // Any SCC with size > 1 is a cycle\n}\n```\n\nOption B: **Simple DFS** (easier to understand)\n```rust\nfn detect_cycles(&self) -> Result> {\n let mut cycles = Vec::new();\n let mut visited = HashSet::new();\n let mut rec_stack = HashSet::new();\n \n for file_id in self.get_all_file_ids()? {\n self.dfs_find_cycles(file_id, &mut visited, &mut rec_stack, &mut cycles)?;\n }\n Ok(cycles)\n}\n```\n\n**The CLI already handles output:**\n```rust\n// cli/cycles.rs already formats output nicely:\n// Cycle 1: a.rs -> b.rs -> c.rs -> a.rs\n```\n\n**Estimated effort: Low-Medium (~100 lines, 2-3 hours)**\n\nJust need to implement the algorithm - all the plumbing exists.","status":"closed","priority":2,"issue_type":"feature","assignee":null,"labels":["drift-inspired","feature","tethys"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-j9bu","dep_type":"parent-child"}],"created_at":"2026-01-29T12:53:28.542509377Z","updated_at":"2026-01-29T15:00:39.567609143Z","closed_at":"2026-01-29T15:00:39.567609143Z"}
{"id":"rivets-ugg6","title":"Add integration tests for cross-file resolution","description":"Add tests in tests/indexing.rs:\n\n- cross_file_callers_via_explicit_import\n- cross_file_callers_via_qualified_path \n- cross_file_callers_via_glob_import\n- csharp_cross_file_via_namespace\n\nEach test creates a multi-file workspace and verifies callers() finds cross-file references.","status":"closed","priority":1,"issue_type":"task","assignee":null,"labels":["phase1","tethys"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-rndz","dep_type":"blocks"},{"depends_on_id":"rivets-zk2q","dep_type":"parent-child"}],"created_at":"2026-01-28T17:27:52.150595727Z","updated_at":"2026-01-28T18:11:03.344673588Z","closed_at":"2026-01-28T18:11:03.344673588Z"}
-{"id":"rivets-2uv","title":"Evaluate rstest for rivets-jsonl/src/warning.rs and atomic.rs inline tests","description":"Evaluate and apply rstest improvements to inline unit tests in warning.rs and atomic.rs.\n\nFiles:\n- crates/rivets-jsonl/src/warning.rs (lines ~392+)\n- crates/rivets-jsonl/src/atomic.rs (lines ~209+)","status":"closed","priority":2,"issue_type":"task","assignee":null,"labels":[],"design":null,"acceptance_criteria":"- Inline tests evaluated for rstest opportunities\n- Parameterization applied where beneficial\n- All tests pass","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-ry6","dep_type":"parent-child"}],"created_at":"2025-11-29T01:19:19.453806850Z","updated_at":"2025-11-29T01:28:55.618500868Z","closed_at":"2025-11-29T01:28:55.618500868Z"}
-{"id":"rivets-xov3","title":"C# parser doesn't extract nested types (classes, records, structs)","description":"The C# parser's `extract_class_members` function only extracts methods and constructors from type declarations. It does NOT recurse into nested type declarations (classes, records, structs, interfaces).\n\n**Impact**: References like `Distribution.Percentage` can't be resolved because `Percentage` (a nested record) is never indexed.\n\n**Root cause**: `extract_class_members()` in `csharp.rs:663-696` only handles `METHOD_DECLARATION` and `CONSTRUCTOR_DECLARATION`. It should also handle `CLASS_DECLARATION`, `RECORD_DECLARATION`, `STRUCT_DECLARATION`, `INTERFACE_DECLARATION`.\n\n**Test added**: `extracts_nested_types` test in `csharp.rs` (currently failing) demonstrates the issue.\n\n**Example**:\n```csharp\npublic abstract record Distribution\n{\n public sealed record Percentage : Distribution { } // NOT extracted\n public sealed record FixedAmount : Distribution { } // NOT extracted\n}\n```\n\nOnly `Distribution` is extracted, not the nested `Percentage` or `FixedAmount` types.","status":"closed","priority":1,"issue_type":"bug","assignee":null,"labels":["csharp-support","parser","tethys"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[],"created_at":"2026-01-30T03:40:30.208935600Z","updated_at":"2026-01-30T03:46:50.536075597Z","closed_at":"2026-01-30T03:46:50.536075597Z"}
-{"id":"rivets-vdi","title":"Wire StorageBackend::Jsonl to use InMemoryStorage::load_from_jsonl","description":"The `StorageBackend::Jsonl(path)` variant exists but returns an error \"JSONL storage backend not yet implemented\". \n\nPer architecture doc, JSONL is persistence for InMemory, not a separate backend. Wire this variant to use `InMemoryStorage::load_from_jsonl(path)`.\n\nThis unblocks the MCP server which already uses `StorageBackend::Jsonl(db_path)` in `context.rs:109`.","status":"closed","priority":1,"issue_type":"task","assignee":null,"labels":[],"design":"Update `create_storage()` in `crates/rivets/src/storage/mod.rs`:\n\n```rust\nStorageBackend::Jsonl(path) => {\n let (storage, warnings) = in_memory::load_from_jsonl(&path, \"rivets\".to_string()).await?;\n if !warnings.is_empty() {\n // Log warnings - storage still usable\n for w in &warnings {\n tracing::warn!(\"JSONL load warning: {:?}\", w);\n }\n }\n Ok(storage)\n}\n```\n\nIf file doesn't exist, create empty InMemoryStorage (first-run case).","acceptance_criteria":"- [ ] `StorageBackend::Jsonl(path)` returns working storage\n- [ ] Loads existing JSONL file if present\n- [ ] Creates empty storage if file doesn't exist (first run)\n- [ ] Warnings logged but don't fail load\n- [ ] MCP server can successfully use Jsonl backend\n- [ ] Unit test for Jsonl backend creation","notes":null,"external_ref":null,"dependencies":[],"created_at":"2025-11-29T23:36:46.342699192Z","updated_at":"2025-11-29T23:40:44.382872584Z","closed_at":"2025-11-29T23:40:44.382872584Z"}
-{"id":"rivets-3hs","title":"Add indicatif dependency for progress indicators","description":"Add indicatif crate to workspace dependencies for progress bars and spinners.","status":"open","priority":2,"issue_type":"task","assignee":null,"labels":["phase-1b","ux"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-6bc","dep_type":"parent-child"}],"created_at":"2025-11-30T18:35:19.476455174Z","updated_at":"2025-11-30T18:35:19.476455174Z","closed_at":null}
-{"id":"rivets-8fe","title":"Add edge case filter tests for MCP integration tests","description":"Add comprehensive edge case tests for the filter functionality in MCP integration tests to ensure robust handling of boundary conditions and special scenarios.","status":"closed","priority":2,"issue_type":"task","assignee":"claude","labels":["mcp","testing"],"design":"Add test cases to `crates/rivets-mcp/tests/integration.rs` covering:\n\n1. **Empty filter results**: Create issues that don't match any filter combination, verify empty vec returned without errors\n\n2. **Multiple labels**: Create issue with labels [\"frontend\", \"backend\", \"urgent\"], filter by \"backend\" should include it\n\n3. **Case sensitivity**: Test whether \"Alice\" matches assignee filter \"alice\" - document expected behavior\n\n4. **Unicode support**: Test with:\n - Japanese title: \"バグ修正\"\n - Emoji label: \"🔥hotfix\"\n - Accented assignee: \"José García\"","acceptance_criteria":"- [ ] Empty filter results - verify graceful handling when no issues match filters\n- [ ] Multiple labels - test filtering when an issue has multiple labels and filter matches one\n- [ ] Case sensitivity - verify label/assignee filters handle case correctly (e.g., \"Alice\" vs \"alice\")\n- [ ] Unicode/special characters - ensure filters work with non-ASCII issue titles, labels, assignees","notes":null,"external_ref":null,"dependencies":[],"created_at":"2025-11-30T01:06:15.222446228Z","updated_at":"2025-11-30T18:09:22.615118183Z","closed_at":"2025-11-30T18:09:22.615118183Z"}
+{"id":"rivets-gut","title":"Implement rivets done workflow command","description":"Add `rivets done` command for completing work:\n\n```\n$ rivets done rivets-abc\nClosing rivets-abc...\nAdd completion notes (optional, Ctrl+D to finish):\n> Fixed authentication flow, added token refresh.\n> PR: #42\n\nClosed: rivets-abc\n - Added close reason\n - Status: in_progress -> closed\n\n2 issues are now unblocked:\n - rivets-def Add OAuth support\n - rivets-ghi User profile update\n```","status":"open","priority":4,"issue_type":"feature","assignee":null,"labels":["phase-4","workflow"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-6bc","dep_type":"parent-child"}],"created_at":"2025-11-30T18:37:56.117458877Z","updated_at":"2025-11-30T18:37:56.117458877Z","closed_at":null}
+{"id":"rivets-o4re","title":"MCP server for tethys tools","description":"Expose tethys functionality as MCP tools for AI agent integration. AI assistants like Claude/Cursor/Copilot can then query the indexed graph instead of grepping files, dramatically cutting tool-call count and context usage on long sessions.\n\nInspired by both Drift (50+ MCP tools across 7 layers) and KiroGraph (16 MCP tools, all auto-approved). KiroGraph's design observation drives most of the value: ''Kiro literally queries the graph instead of grepping files,'' so the index becomes context the model uses for every prompt.\n\n**MVP tools (wrap existing Tethys API methods):**\n\n| Tool | Description | Underlying API |\n|------|-------------|----------------|\n| tethys_status | Index stats, language breakdown | get_stats |\n| tethys_symbol | Get symbol by qualified name | get_symbol |\n| tethys_search | Search symbols by name/kind | search_symbols |\n| tethys_callers | Who calls this symbol? | get_callers |\n| tethys_callees | What does this symbol call? | get_symbol_dependencies |\n| tethys_impact | Impact analysis for a symbol | get_symbol_impact |\n| tethys_file_impact | Impact analysis for a file | get_impact |\n| tethys_reachable | Forward/backward reachability | get_forward_reachable / get_backward_reachable |\n| tethys_cycles | Detect dependency cycles | detect_cycles |\n| tethys_panic_points | Find .unwrap()/.expect() calls | get_panic_points |\n| tethys_affected_tests | Tests affected by changed files | get_affected_tests |\n\n**v2 tools (depend on new analytics — see sibling issues):**\n\n| Tool | Description | Depends on |\n|------|-------------|------------|\n| tethys_path | Shortest path between two symbols | new path-query feature |\n| tethys_dead_code | Symbols with zero incoming refs | new dead-code feature |\n| tethys_hotspots | Most-connected symbols | new hotspots feature |\n| tethys_surprising | Non-obvious cross-file edges | new surprising-connections feature |\n| tethys_type_hierarchy | Up/down extends-implements walk | new type-hierarchy feature |\n| tethys_coupling | Per-package Ca/Ce/instability | new architecture-metrics feature |\n| tethys_diff | Graph diff vs. saved snapshot | new snapshot/diff feature |\n\n**Implementation plan:**\n- New crates/tethys-mcp crate (binary + thin lib).\n- Use rmcp (already in workspace via rivets-mcp).\n- Each tool is a thin async wrapper around a Tethys method.\n- Tools should default to auto-approved in the Claude MCP config so the model can call them freely.\n- Coexists with rivets-mcp on a single MCP host.\n\n**Benefits:**\n- AI agents query codebase structure instead of grepping files.\n- Integrates with Claude Code, Cursor, Copilot, and any MCP-compatible client.\n- Complements rivets-mcp (issue tracker) and tethys's CLI.","status":"open","priority":2,"issue_type":"feature","assignee":null,"labels":["drift-inspired","feature","mcp","tethys"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-j9bu","dep_type":"parent-child"}],"created_at":"2026-01-29T12:49:26.753055176Z","updated_at":"2026-05-10T04:30:37.025624100Z","closed_at":null}
+{"id":"rivets-dhs","title":"Add query performance benchmarks","description":"Create benchmarks to measure query/filter performance and verify the \"1M records in <5s\" target from research.\n\nUses criterion for benchmarking.","status":"open","priority":2,"issue_type":"task","assignee":null,"labels":[],"design":"Create benches/query_benchmarks.rs:\n\n```rust\nuse criterion::{criterion_group, criterion_main, Criterion, BenchmarkId};\nuse rivets_jsonl::*;\n\nfn query_benchmarks(c: &mut Criterion) {\n let mut group = c.benchmark_group(\"query\");\n \n // Benchmark: filter 1M records\n group.bench_function(\"filter_1m_records\", |b| {\n b.iter(|| {\n // ... benchmark code\n });\n });\n \n // Benchmark: filter + map pipeline\n group.bench_function(\"filter_map_pipeline\", |b| {\n b.iter(|| {\n // ... benchmark code\n });\n });\n \n // Benchmark: complex multi-predicate filter\n group.bench_function(\"complex_filter\", |b| {\n b.iter(|| {\n // ... benchmark code\n });\n });\n}\n\ncriterion_group!(benches, query_benchmarks);\ncriterion_main!(benches);\n```\n\nAdd criterion as dev-dependency.","acceptance_criteria":"- Benchmarks created in benches/\n- Measures filter performance\n- Measures map performance \n- Measures complex query pipelines\n- 1M records benchmark runs\n- Results documented in comments\n- cargo bench runs successfully","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-08u","dep_type":"blocks"},{"depends_on_id":"rivets-6p4","dep_type":"blocks"},{"depends_on_id":"rivets-sx8j","dep_type":"parent-child"}],"created_at":"2025-11-27T23:17:06.817308399Z","updated_at":"2025-11-27T23:17:06.817308399Z","closed_at":null}
+{"id":"rivets-qeb","title":"Implement ready work algorithm with recursive blocking","description":"Implement the ready work detection algorithm that finds unblocked issues by recursively propagating blocks through parent-child hierarchies using CTEs.","status":"closed","priority":1,"issue_type":"task","assignee":"claude","labels":[],"design":"Based on beads ready.go, adapted for Phase 1 (in-memory + petgraph):\n\n**Algorithm** (Phase 1 - petgraph):\n1. Find directly blocked issues (via 'blocks' deps to open/in_progress issues)\n2. Recursively propagate blockage through parent-child deps (depth limit 50)\n3. Exclude all blocked issues from results\n\n**Phase 1 Implementation**:\n```rust\nimpl InMemoryStorage {\n fn find_ready(&self, filter: Option<&IssueFilter>) -> Result> {\n use petgraph::Direction;\n \n // Find all blocked issues\n let mut blocked = HashSet::new();\n \n // Direct blocks: issues with blocking dependencies\n for (id, issue) in &self.issues {\n if issue.status == Status::Closed {\n continue;\n }\n \n for dep in &issue.dependencies {\n if dep.dep_type == DependencyType::Blocks {\n let blocker = self.issues.get(&dep.depends_on_id)?;\n if blocker.status == Status::Open || blocker.status == Status::InProgress {\n blocked.insert(id.clone());\n }\n }\n }\n }\n \n // Transitive blocking via parent-child (BFS with depth limit)\n let mut to_process: VecDeque<(IssueId, usize)> = blocked.iter()\n .map(|id| (id.clone(), 0))\n .collect();\n \n while let Some((id, depth)) = to_process.pop_front() {\n if depth >= 50 { continue; }\n \n // Find children (issues that depend on this one via parent-child)\n let node = self.node_map.get(&id)?;\n for edge in self.graph.edges_directed(*node, Direction::Incoming) {\n if edge.weight() == &DependencyType::ParentChild {\n let child_node = edge.source();\n let child_id = &self.graph[child_node];\n if blocked.insert(child_id.clone()) {\n to_process.push_back((child_id.clone(), depth + 1));\n }\n }\n }\n }\n \n // Filter out blocked issues\n let mut ready: Vec = self.issues.values()\n .filter(|issue| {\n issue.status != Status::Closed \n && !blocked.contains(&issue.id)\n })\n .cloned()\n .collect();\n \n // Apply additional filter if provided\n if let Some(filter) = filter {\n ready = self.apply_filter(ready, filter)?;\n }\n \n // Sort by policy (hybrid default)\n self.sort_by_policy(&mut ready, SortPolicy::Hybrid);\n \n Ok(ready)\n }\n}\n```\n\n**Sort Policies**:\n- `hybrid` (default): Recent (48h) by priority, older by age\n- `priority`: P0→P1→P2→P3→P4 strict\n- `oldest`: Creation date ascending\n\n**Phase 3 (PostgreSQL)**: Will use recursive CTEs for blocking propagation (see design notes)","acceptance_criteria":"- Ready issues query excludes blocked work using petgraph\n- Recursive parent-child blocking via BFS traversal\n- All 3 sort policies implemented\n- Depth limit (50) prevents infinite loops\n- Performance: <10ms for 1000 issues\n- Unit tests for complex blocking scenarios\n- Integration test with various dependency graphs","notes":null,"external_ref":null,"dependencies":[],"created_at":"2025-11-17T22:15:21.592668862Z","updated_at":"2025-11-28T19:38:24.156494963Z","closed_at":"2025-11-28T19:38:24.156494963Z"}
+{"id":"rivets-uvv","title":"Fix: Blocked status not counted in execute_info","description":"In execute_info (lines 61-66), the fold counting issue statuses doesn't track Blocked issues separately. They're counted in total but have no dedicated counter, inconsistent with execute_stats which tracks blocked separately.\n\n**Current code:**\n```rust\nlet (total, open, in_progress, closed) =\n all_issues.iter().fold((0, 0, 0, 0), |(t, o, ip, c), issue| match issue.status {\n IssueStatus::Blocked => (t + 1, o, ip, c), // ← Not tracked\\!\n });\n```\n\n**Fix:** Add a blocked counter to the tuple and output.","status":"closed","priority":1,"issue_type":"task","assignee":null,"labels":["bug","execute.rs"],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-q82","dep_type":"parent-child"}],"created_at":"2025-12-28T15:37:17.787185220Z","updated_at":"2025-12-28T16:04:20.692554031Z","closed_at":"2025-12-28T16:04:20.692554031Z"}
+{"id":"rivets-s3o","title":"Implement context management module","description":"Implement workspace context management:\n- Workspace detection (walk up to find .rivets/)\n- Path canonicalization\n- Per-workspace storage instance management\n- Context state storage (Arc>)","status":"closed","priority":1,"issue_type":"task","assignee":null,"labels":[],"design":null,"acceptance_criteria":"- [ ] Workspace detection walks up to find .rivets/\n- [ ] Path canonicalization handles symlinks\n- [ ] Storage instances cached per workspace\n- [ ] Unit tests: discover_workspace, set_workspace, storage caching","notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-4dw","dep_type":"parent-child"},{"depends_on_id":"rivets-cfx","dep_type":"blocks"}],"created_at":"2025-11-29T01:16:11.298145823Z","updated_at":"2025-11-29T14:41:24.606759038Z","closed_at":"2025-11-29T14:41:24.606759038Z"}
{"id":"rivets-uqv","title":"Add JSONL export mirroring to AutomergeStorage","description":"Enable optional JSONL file generation on save() for human readability and git diff friendliness.","status":"closed","priority":2,"issue_type":"task","assignee":null,"labels":[],"design":null,"acceptance_criteria":null,"notes":null,"external_ref":null,"dependencies":[{"depends_on_id":"rivets-5vz","dep_type":"blocks"}],"created_at":"2025-11-30T19:10:38.970619144Z","updated_at":"2025-12-23T04:45:11.249724356Z","closed_at":"2025-12-23T04:45:11.249724356Z"}
-{"id":"rivets-0gc","title":"Design and implement storage trait abstraction","description":"Create the Storage trait abstraction that will allow multiple backend implementations (in-memory, JSONL, PostgreSQL). This is the foundation for the phased storage approach.\n\nPhase 1: Storage Trait + In-Memory Graph (MVP)\n- Define Storage trait with all required methods\n- In-memory implementation using HashMap + petgraph\n- JSONL persistence as backup/export format\n\nPhase 2: PostgreSQL with Recursive CTEs (Production)\n- PostgreSQL implementation with recursive CTEs for dependency queries\n- Migration path from in-memory to PostgreSQL\n\nThis task focuses on the trait definition and abstraction layer.\n\n## Clarifications\n\n### Session 2025-11-17\n\n- Q: Should the IssueStorage trait be async or synchronous? → A: Make trait async from start using async-trait crate (future-proof for PostgreSQL, enables non-blocking I/O)\n- Q: How should storage operations handle concurrent access in the in-memory backend? → A: Wrap in Arc> for thread-safe access (standard pattern, simple for MVP)\n- Q: How should JSONL file corruption be handled during load? → A: Skip invalid lines with warning logs, continue loading valid entries (practical, allows recovery from partial corruption)\n- Q: Which tokio runtime should the CLI use? → A: Current-thread runtime (simpler for CLI, lower overhead, easier debugging)\n- Q: What should happen to dependencies when an issue is deleted? → A: Delete issue's dependencies, fail if issue has dependents with warning (safe, prevents orphaned references)","status":"closed","priority":1,"issue_type":"task","assignee":"claude","labels":[],"design":"## Storage Trait Design\n\n### Core Trait Definition\n\n**Async trait using async-trait crate:**\n\n```rust\nuse async_trait::async_trait;\n\n#[async_trait]\npub trait IssueStorage: Send + Sync {\n // CRUD operations\n async fn create(&mut self, issue: NewIssue) -> Result;\n async fn get(&self, id: &IssueId) -> Result