# Codevira v3.0.0 — Plan + RC audit story

> **Status:** shipped. Tagged at commit `c6aed85`.
> Wheel: `dist/codevira-3.0.0-py3-none-any.whl` (~385 KB).
> Pipx install size: ~85 MB. Cold start: ~50 ms on Python 3.13.
>
> Three rounds of audit ran against the v3.0.0 release candidate
> before publishing. This document captures what each round caught
> and how it was fixed.

---

## Round 1 — Surface cut + dead-code sweep (the 3.0.0 driver)

Driven by the 2026-05-22 surface-cut audit
(`docs/audit-2026-05-22.md` + `docs/surface-cuts-2026-05-22.md`).
The audit traced five categories of user complaints to overgrown
surface, false-positive IDE detections, and "junk left behind"
after uninstall. v3.0.0 fixes all five.

Inputs:

- 46 → 24 MCP tools (-48%). 22 tools that nobody called in real
  usage (preferences, learned_rules, changesets, project_maturity,
  list_nodes / add_node / update_node / export_graph, …) deleted.
- 4 dead engine policies deleted.
- Per-IDE nudge file matrix collapsed to AGENTS.md only.
- IDE detection hardened from "directory exists" to
  "binary on PATH + valid config file."
- `codevira uninstall` added. Reverses every system write made by
  `init` / `setup`.
- Full dead-code sweep across the whole repo (~3,800 lines removed)
  surfaced obviously-dead internal helpers exposed by the cuts.

Result: tagged release candidate, gauntlet green (G1, G1.5, G1.6,
G1.7, G2, G2.5, G4 PASS; G3 stub).

---

## Round 2 — "What's silently broken under load?"

Triggered by a 50-thread stress run of `decisions_store.record`
that surfaced `[Errno 2] No such file or directory: '...manifest.yaml.tmp'`
warnings under contention. The decisions themselves were always
safe (jsonl_store.append uses fcntl-locked I/O), but the CACHE
files (manifest.yaml, digest.jsonl, AGENTS.md) lost data silently.

### Findings + fixes

1. **Atomic-rename race on a fixed `.tmp` suffix.** Three writers
   (`manifest.save`, `digest.regenerate`,
   `agents_md_generator._merge_into_file`) all used
   `path.with_suffix(path.suffix + ".tmp")`. Two threads' `os.replace`
   calls raced on the rename target — thread A's tmp got consumed
   by its own `replace()`, thread B's later `replace()` raised
   `FileNotFoundError`. Fixed by per-write unique tmp via
   `tempfile.mkstemp` in each module.

2. **Lost-update race in `manifest.incremental_add`.** Each call
   did `load → mutate → save` without locking. 50 concurrent calls
   loaded the same starting state, mutated their copies, then
   raced on save — last writer won, losing the other 49 updates.
   Fixed by `fcntl.flock` around the whole read-modify-write.

3. **`signals.decisions` was reading a SQL table that no longer
   exists in v3.0.0.** The signal layer's `decisions()` accessor
   pulled from `<data_dir>/graph/graph.db::decisions`, but v3.0.0
   moved the canonical decision log to `.codevira/decisions.jsonl`.
   `DecisionLock` policy was structurally silenced on every v3.0.0
   project. Rewired to read JSONL via `decisions_store`.

4. **`signals.graph` only checked v1.5 / v1.6 paths.** Round-2 fix
   added `<project>/.codevira-cache/graph.sqlite` as the priority-1
   resolution tier so the v3.0.0 spec location wins when present.
   (See "Known limitations" below — runtime still falls back to
   the centralized location because that's where `indexer/`
   actually writes the graph today.)

### Regression tests added in round 2

`tests/storage/test_concurrent_writes.py`:
- `TestAtomicWriteRace.test_no_atomic_rename_warnings_under_concurrency`
- `TestManifestLostUpdates.test_manifest_matches_jsonl_after_concurrent_writes`
- `TestCanonicalStoreSurvivesEvenIfCacheFails.test_decision_persists_when_manifest_corrupt`
  (P9 invariant — decisions land in JSONL even if the manifest cache fails)

`tests/engine/test_qa_round3.py`:
- Regression guard: `signals.decisions` MUST read JSONL, not SQL.

Plus a ruff cleanup pass on 6 files (F541, E741, F841, E402, E702
warnings) to keep `make ci` green.

---

## Round 3 — "Did round-2 leave any sibling bugs?"

User asked: *"have you checked thoroughly?"* Honest answer was no —
round-2 spot-fixed three writers but didn't sweep the rest. Round-3
did the systematic sweep.

### Findings + fixes

1. **Roadmap.yaml had the same race shape as round-2's manifest,
   on a hotter path.** `roadmap._save_roadmap` did an unguarded
   `open(... "w") + yaml.dump`. Six mutation paths (`add_phase`,
   `update_phase_status`, `defer_phase`, `complete_phase`,
   `bulk_import_phases`, `update_next_action`) all funnel through
   it — and CLAUDE.md flags session-end phase update as
   non-negotiable. Two agents in two IDEs wrapping up together
   would silently lose one update each. Fixed via `_roadmap_lock()`
   wrapping the whole read-modify-write.

2. **5 hand-rolled atomic-write copies had drifted.** `manifest`,
   `digest`, `agents_md_generator`, `setup_wizard`, and
   `jsonl_store` each had their own implementation of the
   `mkstemp + fsync + os.replace` dance. Consolidated into a new
   `mcp_server/storage/atomic.py` module with three public helpers:
   `atomic_write_text(path, content, *, mode=None)`,
   `atomic_write_bytes(path, content, *, mode=None)`, and
   `file_lock(path, *, exclusive=True)` (context manager — Posix
   `fcntl.flock` + Windows sentinel-file fallback). 11 unit tests
   pin the contract.

3. **11 more unguarded writes found.** A full sweep across
   `mcp_server/` and `indexer/` surfaced sites in `auto_init`,
   `cli_init`, `http_server` (bearer token write — auth-critical),
   `cli_uninstall` (settings.json + AGENTS.md mutations),
   `cli_hooks_admin` (fixed-suffix tmp race), `cli_export`
   (fixed-suffix tmp race), `cli_replay`, `log_retention`,
   `migrate`, `cli` (legacy configure + git hook), and
   `indexer/graph_generator` (roadmap stub). All repointed at
   `storage.atomic`.

4. **Process-level safety unproven.** Round-2 tests were
   thread-level. Two `codevira` MCP server subprocesses (Claude
   Desktop + Cursor running together) racing on the same project
   would still lose updates if `fcntl.flock` didn't survive process
   boundaries. New file `tests/storage/test_cross_process_writes.py`
   spawns 20 subprocesses via `multiprocessing.spawn` and asserts:
   20 concurrent `decisions_store.record` → 20 unique IDs;
   20 concurrent `roadmap.add_phase` → all 20 phases land.

5. **Engine policy storage-correctness audit.** Each of the 6
   active policies (`anti_regression`, `blast_radius`,
   `decision_lock`, `post_edit_refresh`, `relevance_inject`,
   `token_budget`) was read line-by-line to confirm v3.0.0 storage
   usage. All good — round-2's signals fixes propagated correctly.

6. **MCP tools/list schema audit.** All 23 surfaced tools validated:
   every required field is in properties, every tool has a
   description, every schema is `type: object`. Zero issues.

7. **Doctor dogfooded** against this project + a fresh init.
   Fresh init: 13 pass / 1 warn (pre-existing ghost dirs) / 0 fail.

8. **Windows lock parity.** Round-2's manifest fix used
   `fcntl.flock` with no Windows fallback — silently no-op'd on
   Windows, restoring the lost-update bug. The new `atomic.file_lock`
   in round-3 has both paths (Posix + Windows sentinel) so
   consolidating at the helper layer fixed this transparently.

### Adversarial chaos probe — `scripts/chaos_smoke.py`

Round-3 also added `scripts/chaos_smoke.py`: 8 attack categories,
29 sub-tests, "what breaks under hacker-mode adversarial input?"

| # | Attack | Result |
|---|---|---|
| 1 | Adversarial decision input (null bytes, 1 MB text, `../../etc/passwd`, `/etc/passwd`, control chars, empty, unicode, emoji) + 3 type-confusion cases | 14 PASS — all accepted gracefully OR rejected cleanly |
| 2 | SIGKILL a process holding `fcntl.flock` | PASS — lock released in 0 ms |
| 3 | Corrupt `decisions.jsonl` mid-file + corrupt `manifest.yaml` + corrupt `AGENTS.md` | 3 PASS — graceful degradation |
| 4 | 200 mixed concurrent ops (20 workers) across decisions + phases + sessions | PASS — 0 errors |
| 5 | 20-thread lock-contention storm | PASS — serialized in 1.97 s, no starvation |
| 6 | Symlink `AGENTS.md → /tmp/innocent` | PASS — `os.replace` replaces symlink itself, victim safe |
| 7 | 7 malformed JSON-RPC payloads to `codevira serve` | 7 PASS — server didn't crash |
| 8 | `chmod 0555` the `.codevira/` dir | PASS — `PermissionError` raised gracefully |

Final: **29 PASS / 0 FAIL.**

### Notable design choices the chaos harness surfaced

- **No decision-input validation.** Null bytes, 1 MB text, path
  traversal in `file_path`, control chars — all accepted today.
  Trust-the-agent design choice. v3.1 candidate for sanitization.
- **Symlink safety is automatic.** `atomic.os.replace` replaces the
  symlink itself, not the target — an attacker who plants
  `AGENTS.md → /etc/passwd` can't escape the project root.
- **fcntl.flock survives SIGKILL** (kernel reaps fd-bound locks).

---

## Known limitations (documented for v3.1+ follow-up)

1. **Graph spec vs implementation drift.**
   `paths.graph_cache_path()` documents the v3.0.0 spec location
   as `<project>/.codevira-cache/graph.sqlite`, but `indexer/`
   still writes to and `tools/graph.py` still reads from
   `<data_dir>/graph/graph.db` (centralized v1.6 location).
   Runtime behavior is correct — everyone agrees on the
   centralized location and `signals._load_graph()` finds it via
   the fallback chain. The spec/impl reconciliation is a v3.1
   follow-up that needs a migration step for existing installs.

2. **No decision-input sanitization** (see chaos finding #1).

3. **Cross-process flock tested on macOS only.** The
   `multiprocessing.spawn` context is the same on Linux CI but
   the cross-process test has not been exercised on Windows
   (where the helper uses the sentinel-file fallback).

4. **G3 (real-IDE smoke) is still a stub.** Cold-install smoke
   (G2.5) covers the install path; the actual cross-IDE handoff
   (Claude Code → Cursor → Antigravity record/retrieve) is
   verified manually. v3.1 should fill in the script.

---

## Verification end-to-end (run before tagging v3.0.0 → PyPI)

```bash
# 1. Unit + integration + e2e
.venv/bin/python -m pytest tests/ -q --ignore=tests/e2e
.venv/bin/python -m pytest tests/e2e/ -q --timeout=120

# 2. Storage-layer concurrency proof
.venv/bin/python -m pytest tests/storage/ -v

# 3. Adversarial probe
.venv/bin/python scripts/chaos_smoke.py
# Expect: 29 PASS / 0 FAIL.

# 4. Release gauntlet
PYTHON=.venv/bin/python make release-gauntlet
# Expect: G1, G1.5, G1.6, G1.7, G2, G2.5, G4 all PASS;
#         G3 skipped (pre-existing stub).

# 5. Fresh-install smoke (cold venv from PyPI wheel)
make smoke-install

# 6. Trial install in fresh venv
TMP=$(mktemp -d) && /usr/local/bin/python3.13 -m venv $TMP/venv
$TMP/venv/bin/pip install dist/codevira-3.0.0-py3-none-any.whl
du -sh $TMP/venv                                 # MUST be <100 MB
time $TMP/venv/bin/codevira --version            # MUST be <100 ms
$TMP/venv/bin/python -c "import torch"           # MUST fail
$TMP/venv/bin/python -c "import chromadb"        # MUST fail

# 7. Dogfood codevira on this project
codevira doctor                                  # 11+ pass, no fail
```

G5 (human verification on a real machine) is the only gate the
automation cannot satisfy. The maintainer sets
`.release-evidence/3.0.0.json::G5_human_confirmed: true` after
manually verifying cross-IDE handoff on their own machine, then
publishes.
