# Bug Registry + Scan Primitives Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extend the derived `ledger.py` registry to render every defect's full lifecycle (Discovered → Re-discovery → Resolution → Prevention) and ship the first real `SolutionAdapter` (S4 SQLi) end-to-end, proving "add a bug = register, not rewrite the engine."

**Architecture:** The registry is NOT a new store — it is `ledger.py`'s existing one-row-per-defect projection (ARCHITECTURE §5), extended once with a `solution_classes()`/`resolution_grade()` derivation + Discovered/Resolution columns. Exactly one new executable primitive — `SolutionAdapter` (a drop-in `domains/*/solutions/*/` dir + `solution.json` + `solve.py`, discovered by the same glob discipline as detectors). It **consumes the detector finding, never re-detects**, and emits a `located-suggestion` Resolution (auto-apply gated HARDER — semantic fixes never silent-rewrite). Provenance is plain data on the cell; Prevention derives from the existing ratchet + detectors. SoT: `docs/specs/2026-06-20-bug-registry-primitives-design.md`.

**Tech Stack:** Python 3 (stdlib only: `json`/`re`/`os`/`glob`/`subprocess`), pytest. Language-agnostic `exec`+stdin-JSON contract (port-ready, Python now).

---

## Setup (before Wave 1)

Execution runs on a worktree, never bare `master`:

```bash
cd ~/Projects/security-gate
git worktree add .worktrees/bug-registry-primitives -b bug-registry-primitives
cd .worktrees/bug-registry-primitives
```

All task paths below are repo-relative (resolve inside the worktree). `python3` invoked from the worktree root.

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1, Task 2, Task 4 | `prevent/contract.py`+test; `prevent/registry.py`+test; `domains/security/corpus/S4-kb-spaceids-sqli/canonical.json`+`corpus/README.md` | ✅ zero overlap |
| 2 | Task 3 | `domains/security/solutions/sql-parameterize/{solution.json,solve.py}`+test | single task (blocked by 1) |
| 3 | Task 5 | `ledger.py`+test | single task (blocked by 2) |
| 4 | Task 6 | `tests/test_addabug_register.py` | single task (blocked by 3,4,5) |
| 5 | Task 7 | — (verification + final commit) | single task (blocked by all) |

## File Structure

- `prevent/contract.py` — **modify**: add `resolution()` builder (frozen Resolution shape + no-false-clean + auto-apply gate). Sibling to `finding()`/`emit()`.
- `prevent/registry.py` — **modify**: generalize `load(domains_root)` → `load(domains_root, kind="detector"|"solution")`.
- `domains/security/solutions/sql-parameterize/solution.json` — **create**: the S4 SolutionAdapter manifest.
- `domains/security/solutions/sql-parameterize/solve.py` — **create**: the adapter exec (consumes finding → Resolution).
- `domains/security/corpus/S4-kb-spaceids-sqli/canonical.json` — **modify**: add optional `provenance` sub-schema.
- `domains/security/corpus/README.md` — **modify**: document the optional `provenance` cell field.
- `ledger.py` — **modify**: `solution_classes()`+`resolution_grade()`, `build(..., solutions=())`, Discovered/Resolution row fields, lifecycle table in `_md()`.
- `tests/test_contract_resolution.py`, `tests/test_registry_solution_load.py`, `tests/test_solution_sql_parameterize.py`, `tests/test_ledger_lifecycle.py`, `tests/test_addabug_register.py` — **create**.

---

### Task 1: `contract.resolution()` emitter

**Wave:** 1
**Blocks:** Task 3, Task 5
**Blocked by:** —

**Files:**
- Modify: `prevent/contract.py` (append after `emit()`, line 26)
- Test: `tests/test_contract_resolution.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/test_contract_resolution.py
import importlib.util, os, pytest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_spec = importlib.util.spec_from_file_location("sg_contract", os.path.join(ROOT, "prevent", "contract.py"))
contract = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(contract)

LOC = {"file": "kb.ts", "line": 281, "symbol": "listArticlesBySpaceIds"}

def test_located_suggestion_ok_no_patch():
    r = contract.resolution("sql-parameterize", "S4", "located-suggestion", LOC, "use inArray(...)")
    assert r["status"] == "ok" and r["patch"] is None and r["rung"] == "located-suggestion"
    assert r["adapter"] == "sql-parameterize" and r["class"] == "S4" and r["location"] == LOC

def test_unresolved_auto_degrades():
    r = contract.resolution("a", "S4", "located-suggestion", LOC, "s", unresolved=["could not derive args"])
    assert r["status"] == "degraded" and r["coverage"]["unresolved"] == ["could not derive args"]

def test_patch_rejected_on_located_suggestion():
    with pytest.raises(ValueError):
        contract.resolution("a", "S4", "located-suggestion", LOC, "s", patch="diff")

def test_patch_rejected_when_degraded():
    with pytest.raises(ValueError):
        contract.resolution("a", "S4", "syntactic-local", LOC, "s", patch="diff", unresolved=["x"])

def test_patch_ok_on_syntactic_local():
    r = contract.resolution("a", "S4", "syntactic-local", LOC, "s", patch="diff")
    assert r["status"] == "ok" and r["patch"] == "diff"

def test_bad_rung_raises():
    with pytest.raises(ValueError):
        contract.resolution("a", "S4", "auto-magic", LOC, "s")
```

- [ ] **Step 2: Run to verify it fails**

Run: `python3 -m pytest tests/test_contract_resolution.py -v`
Expected: FAIL — `AttributeError: module 'sg_contract' has no attribute 'resolution'`

- [ ] **Step 3: Implement `resolution()` in `prevent/contract.py`**

Append after line 26 (after `emit()`):

```python

RUNGS = ("syntactic-local", "located-suggestion")  # auto-applyable vs agent/human-applied (ARCHITECTURE §gated HARDER)

def resolution(adapter, cls, rung, location, suggestion, patch=None, status=None, unresolved=()):
    """Build the validated Resolution dict a SolutionAdapter emits (mirrors finding(): returns a dict, never prints).
    no-false-clean + resolution-gated-HARDER live HERE:
      - rung must be a known RUNG (raise, not assert — survives `python3 -O`);
      - a non-empty `unresolved` auto-degrades status (a partial/uncertain fix can never serialize 'ok');
      - a `patch` is legal ONLY on rung=='syntactic-local' AND status=='ok'. A located-suggestion or a degraded
        resolution that carried a patch would be a silent rewrite of insecure code under uncertainty — worse than
        no fix. The emitter refuses it (defense in depth; the runner/ledger also gate auto-apply)."""
    if rung not in RUNGS:
        raise ValueError(f"bad rung {rung!r} (must be one of {RUNGS})")
    if status is None:
        status = "degraded" if unresolved else "ok"
    if patch is not None and (rung != "syntactic-local" or status != "ok"):
        raise ValueError("patch is legal ONLY on rung=='syntactic-local' AND status=='ok' "
                         "(auto-apply gated HARDER; semantic/uncertain fixes emit a located suggestion, never a patch)")
    return {"adapter": adapter, "class": cls, "rung": rung, "status": status,
            "location": location, "suggestion": suggestion, "patch": patch,
            "coverage": {"unresolved": list(unresolved)}}
```

- [ ] **Step 4: Run to verify it passes**

Run: `python3 -m pytest tests/test_contract_resolution.py -v`
Expected: PASS (6 passed)

- [ ] **Step 5: Commit**

```bash
git add prevent/contract.py tests/test_contract_resolution.py
git commit -m "feat(contract): resolution() emitter — no-false-clean + auto-apply gated to syntactic-local"
```

---

### Task 2: generalize `registry.load(kind=)`

**Wave:** 1
**Blocks:** Task 5
**Blocked by:** —

**Files:**
- Modify: `prevent/registry.py:9-22` (the `load()` function)
- Test: `tests/test_registry_solution_load.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/test_registry_solution_load.py
import importlib.util, json, os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_spec = importlib.util.spec_from_file_location("sg_registry", os.path.join(ROOT, "prevent", "registry.py"))
registry = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(registry)

def test_detector_default_unchanged():
    dets, skipped = registry.load(ROOT)                 # default kind='detector' — every existing caller
    assert any(d.get("id") == "deps" for d in dets) and skipped == []

def test_solution_kind_discovers_manifest(tmp_path):
    d = tmp_path / "domains" / "security" / "solutions" / "x"
    d.mkdir(parents=True)
    (d / "solution.json").write_text(json.dumps({"id": "x", "class": "S4", "rung": "located-suggestion"}))
    sols, skipped = registry.load(str(tmp_path), kind="solution")
    assert len(sols) == 1 and sols[0]["id"] == "x" and sols[0]["_dir"] == str(d) and skipped == []

def test_solution_malformed_skipped(tmp_path):
    d = tmp_path / "domains" / "security" / "solutions" / "bad"
    d.mkdir(parents=True)
    (d / "solution.json").write_text("{not json")
    sols, skipped = registry.load(str(tmp_path), kind="solution")
    assert sols == [] and len(skipped) == 1            # malformed → surfaced, never silently dropped
```

- [ ] **Step 2: Run to verify it fails**

Run: `python3 -m pytest tests/test_registry_solution_load.py -v`
Expected: FAIL — `TypeError: load() got an unexpected keyword argument 'kind'`

- [ ] **Step 3: Generalize `load()`**

Replace `prevent/registry.py:9-22` (the whole `load` function) with:

```python
_GLOBS = {"detector": ("detectors", "detector.json"), "solution": ("solutions", "solution.json")}

def load(domains_root, kind="detector"):
    """domains/*/<kind-dir>/*/<manifest>.json → (manifests, skipped). kind='detector' (DEFAULT — unchanged for
    every existing caller) discovers detector.json; kind='solution' discovers domains/*/solutions/*/solution.json.
    detectors/solutions=[manifest dict + '_dir']; skipped=[(path, reason)] for manifests that failed to parse.
    A malformed manifest is SKIPPED + surfaced (the consumer maps it to reduced coverage — never a silent drop)."""
    sub, fname = _GLOBS[kind]
    out, skipped = [], []
    for mf in sorted(glob.glob(os.path.join(domains_root, "domains", "*", sub, "*", fname))):
        try:
            m = json.load(open(mf, encoding="utf-8"))
        except (OSError, ValueError) as e:
            skipped.append((mf, str(e)))  # malformed → surfaced as reduced coverage (no-false-clean)
            continue
        m["_dir"] = os.path.dirname(mf)
        out.append(m)
    return out, skipped
```

- [ ] **Step 4: Run to verify it passes (+ no regression on existing registry tests)**

Run: `python3 -m pytest tests/test_registry_solution_load.py tests/test_prevent_registry.py -v`
Expected: PASS (3 new + existing prevent_registry tests green)

- [ ] **Step 5: Commit**

```bash
git add prevent/registry.py tests/test_registry_solution_load.py
git commit -m "feat(registry): load(kind=detector|solution) — same glob discipline, default unchanged"
```

---

### Task 3: first `SolutionAdapter` — `sql-parameterize` (S4)

**Wave:** 2
**Blocks:** Task 5 (real-data row), Task 6
**Blocked by:** Task 1 (imports `contract.resolution`)

**Files:**
- Create: `domains/security/solutions/sql-parameterize/solution.json`
- Create: `domains/security/solutions/sql-parameterize/solve.py`
- Test: `tests/test_solution_sql_parameterize.py`

The conformance fixture is the EXISTING committed ground truth `domains/security/corpus/S4-kb-spaceids-sqli/{vuln,safe}.ts` (no duplicate cell). Vuln sink (`vuln.ts:281`): `sql\`${kbArticles.spaceId} = ANY(${sql.raw(\`ARRAY[${spaceIds.map((id) => \`'${id}'\`).join(',')}]::uuid[]\`)})\``. Safe (`safe.ts:281`): `inArray(kbArticles.spaceId, spaceIds)`.

- [ ] **Step 1: Write the failing conformance tests**

```python
# tests/test_solution_sql_parameterize.py
import json, os, subprocess
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SOLVE = os.path.join(ROOT, "domains", "security", "solutions", "sql-parameterize", "solve.py")
S4 = os.path.join(ROOT, "domains", "security", "corpus", "S4-kb-spaceids-sqli")
FIND = {"class": "S4", "file": "packages/db/src/queries/kb.ts", "line": 281, "symbol": "listArticlesBySpaceIds"}

def _run(finding, content):
    p = subprocess.run(["python3", SOLVE], input=json.dumps({"finding": finding, "file_content": content}),
                       capture_output=True, text=True)
    assert p.returncode == 0, p.stderr
    return json.loads(p.stdout)

def _read(name):
    with open(os.path.join(S4, name), encoding="utf-8") as f:
        return f.read()

def test_vuln_emits_SPECIFIC_inarray_suggestion():
    r = _run(FIND, _read("vuln.ts"))
    assert r is not None and r["rung"] == "located-suggestion" and r["status"] == "ok" and r["patch"] is None
    assert "inArray(kbArticles.spaceId, spaceIds)" in r["suggestion"]   # concrete shape, NOT generic advice

def test_safe_abstains_idempotent():
    assert _run(FIND, _read("safe.ts")) is None        # already parameterized → no resolution

def test_nonmatching_class_abstains():
    assert _run({**FIND, "class": "S1"}, _read("vuln.ts")) is None      # consume, don't re-detect

def test_absent_symbol_abstains():
    assert _run({**FIND, "symbol": "noSuchFunction"}, _read("vuln.ts")) is None
```

- [ ] **Step 2: Run to verify it fails**

Run: `python3 -m pytest tests/test_solution_sql_parameterize.py -v`
Expected: FAIL — `FileNotFoundError` / non-zero return (solve.py absent)

- [ ] **Step 3: Create the manifest `solution.json`**

```json
{"id":"sql-parameterize",
 "class":"S4",
 "strategy":"parameterize-query",
 "applies_to":{"class":"S4","signal":"sql\\.raw"},
 "exec":["python3","domains/security/solutions/sql-parameterize/solve.py"],
 "rung":"located-suggestion",
 "kind":"semantic"}
```

- [ ] **Step 4: Create the adapter `solve.py`**

```python
#!/usr/bin/env python3
"""solve.py — SolutionAdapter 'sql-parameterize' (S4). CONSUMES the detector finding (file+symbol+class), NEVER
re-detects the bug: it locates the named function in file_content and, IFF the raw `${col} = ANY(... sql.raw(...))`
interpolation shape is still present in that body, emits a LOCATED SUGGESTION naming the concrete parameterized
replacement `inArray(<col>, <param>)` (args derived from the sink — proving an adapter, not a lookup table).
Already-parameterized (no raw sink) → ABSTAIN (idempotent). Wrong class / absent symbol → ABSTAIN. rung is
located-suggestion: it NEVER emits a patch (semantic fix — the agent/human applies it)."""
import json, os, re, sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", "..", "..", ".."))   # solutions/sql-parameterize → security-gate root
sys.path.insert(0, os.path.join(ROOT, "prevent"))
import contract  # noqa: E402

ADAPTER, CLS = "sql-parameterize", "S4"
COL = re.compile(r"\$\{\s*(?P<col>[\w.]+)\s*\}\s*=\s*ANY\(")   # ${kbArticles.spaceId} = ANY(
RAW = re.compile(r"sql\.raw\(")
PARAM = re.compile(r"\$\{\s*(?P<param>[\w.]+)\s*\.map\(")       # ${spaceIds.map(

def _func_body(src, symbol):
    """Brace-balanced source of the function named `symbol` (from its first '{'), or '' if absent."""
    i = src.find(symbol)
    if i < 0:
        return ""
    b = src.find("{", i)
    if b < 0:
        return ""
    depth = 0
    for j in range(b, len(src)):
        if src[j] == "{":
            depth += 1
        elif src[j] == "}":
            depth -= 1
            if depth == 0:
                return src[b:j + 1]
    return src[b:]

def propose(finding, file_content):
    if (finding.get("class") or "") != CLS:
        return None                                    # not our class → abstain (consume, never re-detect)
    symbol = finding.get("symbol") or ""
    body = _func_body(file_content, symbol)
    if not body:
        return None                                    # symbol absent → abstain
    cm = COL.search(body)
    if not (cm and RAW.search(body)):
        return None                                    # no raw `= ANY(sql.raw(...))` sink → already fixed → abstain
    col = cm.group("col")
    loc = {"file": finding.get("file", ""), "line": finding.get("line", 0), "symbol": symbol}
    pm = PARAM.search(body)
    if pm:
        return contract.resolution(
            ADAPTER, CLS, "located-suggestion", loc,
            f"Replace the raw `sql.raw(ARRAY[...])` interpolation with the parameterized "
            f"`inArray({col}, {pm.group('param')})` — Drizzle binds the array, eliminating the quote-injection sink.")
    # raw sink present but the bound array could not be auto-derived → degraded suggestion, never a fabricated arg
    return contract.resolution(
        ADAPTER, CLS, "located-suggestion", loc,
        f"Replace the raw `sql.raw(ARRAY[...])` interpolation at `{symbol}` with a parameterized "
        f"`inArray(<column>, <bound-array>)` / bound placeholders.",
        unresolved=[f"could not auto-derive inArray args for {symbol}"])

def main():
    payload = json.load(sys.stdin)
    res = propose(payload.get("finding", {}), payload.get("file_content", ""))
    json.dump(res, sys.stdout)   # JSON `null` when abstaining — an explicit no-resolution, never a fabricated one
    sys.stdout.write("\n")

if __name__ == "__main__":
    main()
```

- [ ] **Step 5: Run to verify it passes**

Run: `python3 -m pytest tests/test_solution_sql_parameterize.py -v`
Expected: PASS (4 passed)

- [ ] **Step 6: Commit**

```bash
git add domains/security/solutions/sql-parameterize/solution.json domains/security/solutions/sql-parameterize/solve.py tests/test_solution_sql_parameterize.py
git commit -m "feat(solution): sql-parameterize S4 adapter — consumes finding, located-suggestion, conformance green"
```

---

### Task 4: cell `provenance` sub-schema (Discovered)

**Wave:** 1
**Blocks:** Task 6
**Blocked by:** —

**Files:**
- Modify: `domains/security/corpus/S4-kb-spaceids-sqli/canonical.json`
- Modify: `domains/security/corpus/README.md`

`ledger._repo_of` already reads `cell.provenance.repo` — no ledger code change here; this is plain data. Backward-compatible (absent → unknown).

- [ ] **Step 1: Add `provenance` to the S4 cell**

Read `domains/security/corpus/S4-kb-spaceids-sqli/canonical.json`, then add a `provenance` key (keep all existing keys verbatim). The full file becomes (single line, matching the existing one-line style):

```json
{"id":"S4-kb-spaceids-sqli","domain":"security","class":"S4","band":1,"shape":"single-file","file":"packages/db/src/queries/kb.ts","line":0,"fix_sha":"9b0c485","canonical_symbol":"listArticlesBySpaceIds sql.raw ARRAY interpolation","why":"listArticlesBySpaceIds interpolates spaceIds into a raw SQL ARRAY[...]::uuid[] literal via sql.raw — each id wrapped in unescaped single quotes (`'${id}'`) → SQL injection on a `'`-bearing id; fix replaces it with parameterized inArray(kbArticles.spaceId, spaceIds)","provenance":{"fix_sha":"9b0c485","discovered_by":"harvest","discovered_when":"2026-06-18","finding_ref":"listArticlesBySpaceIds raw sql.raw ARRAY[...] quote-injection; fix=inArray"}}
```

(Note: `audit`/`repo` are intentionally OMITTED — not verifiable here; the schema is open and `_repo_of` falls back to `""` exactly as today, no regression. Do NOT fabricate them.)

- [ ] **Step 2: Verify the cell still parses + ledger still loads it**

Run: `python3 -c "import json; json.load(open('domains/security/corpus/S4-kb-spaceids-sqli/canonical.json')); print('ok')"`
Expected: `ok`
Run: `python3 ledger.py --check`
Expected: exit 0 (no new gap — provenance is data)

- [ ] **Step 3: Document the optional field in the corpus README**

Read `domains/security/corpus/README.md`. Add this block under the cell-schema/field section (append at end if no such section exists):

```markdown

## Optional `provenance` (Discovered lifecycle)

A cell MAY carry an optional `provenance` object — the **Discovered** record the registry (`ledger.py`) surfaces:

```json
"provenance": {"audit": "<corpus/audit name>", "fix_sha": "<sha>", "discovered_by": "harvest|agent|<name>",
               "discovered_when": "YYYY-MM-DD", "finding_ref": "<one-line description / link>", "repo": "<repo>"}
```

All keys optional. `ledger._repo_of` reads `provenance.repo` for the dedup key (falls back to `""`). Absent
`provenance` → the cell itself is still the Discovered record (the row shows `fix_sha`). NEVER fabricate `audit`/
`repo` — omit what is not verifiable (no-false-coverage applies to provenance too).
```

- [ ] **Step 4: Commit**

```bash
git add domains/security/corpus/S4-kb-spaceids-sqli/canonical.json domains/security/corpus/README.md
git commit -m "feat(corpus): optional provenance sub-schema (Discovered) + S4 provenance — plain data, no-false-coverage"
```

---

### Task 5: `ledger.py` lifecycle extension (one-time)

**Wave:** 3
**Blocks:** Task 6
**Blocked by:** Task 2 (`manifest.load(kind="solution")`)

This is the SINGLE foundational `ledger.py` change. After it, adding a defect is register-only (zero further engine edits).

**Files:**
- Modify: `ledger.py` (add helpers after line 69; `build()` signature + row at 88/116-122; `_md()` at 177-186; `main()` at 189-206)
- Test: `tests/test_ledger_lifecycle.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/test_ledger_lifecycle.py
import importlib.util, os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _load(name, path):
    s = importlib.util.spec_from_file_location(name, path); m = importlib.util.module_from_spec(s); s.loader.exec_module(m); return m
cov = _load("sg_ledger", os.path.join(ROOT, "ledger.py"))

S4CELL = {"id": "S4-x", "class": "S4", "file": "kb.ts", "fix_sha": "9b0c485", "canonical_symbol": "listArticlesBySpaceIds", "_dir": "/x", "provenance": {"discovered_by": "harvest"}}
DET = {"id": "baseline", "_dir": "/b", "covers": ["S4"]}
CLASSES = ["S4"]

def _build(solutions):
    return cov.build([S4CELL], [DET], [], CLASSES, solutions)

def test_resolution_none_without_adapter():
    row = _build(())["defects"][0]
    assert row["resolution"] == "none"

def test_resolution_suggested_for_located_adapter():
    row = _build([{"id": "s", "class": "S4", "rung": "located-suggestion"}])["defects"][0]
    assert row["resolution"] == "suggested"

def test_resolution_auto_for_syntactic_adapter():
    row = _build([{"id": "s", "class": "S4", "rung": "syntactic-local"}])["defects"][0]
    assert row["resolution"] == "auto"

def test_resolution_via_applies_to_class():
    row = _build([{"id": "s", "applies_to": {"class": "S4"}, "rung": "located-suggestion"}])["defects"][0]
    assert row["resolution"] == "suggested"

def test_row_carries_provenance():
    assert _build(())["defects"][0]["provenance"] == {"discovered_by": "harvest"}

def test_md_has_lifecycle_table():
    reg = _build([{"id": "s", "class": "S4", "rung": "located-suggestion"}])
    md = cov._md(reg)
    assert "Lifecycle (one row per defect)" in md and "| S4-x | S4 |" in md and "suggested" in md

def test_resolution_graded_not_a_gap():
    reg = _build(())                                  # no adapter → 'none', but NEVER a CI gap
    assert not any(g["kind"].startswith("resolution") for g in reg["gaps"])
```

- [ ] **Step 2: Run to verify it fails**

Run: `python3 -m pytest tests/test_ledger_lifecycle.py -v`
Expected: FAIL — `build()` takes 4 positional args / `resolution` is `"n/a (no SolutionAdapter)"`

- [ ] **Step 3: Add `solution_classes()` + `resolution_grade()` helpers**

In `ledger.py`, insert after line 69 (after `detector_classes()`):

```python
def solution_classes(s):
    """Classes a SolutionAdapter manifest covers: `class` (singular) ∪ `applies_to.class`. Mirror of
    detector_classes for the solution side (kept narrow — solutions declare one target class)."""
    out = set()
    if s.get("class"):
        out.add(s["class"])
    ap = s.get("applies_to") or {}
    if ap.get("class"):
        out.add(ap["class"])
    return out


def resolution_grade(cls, solutions):
    """Resolution lifecycle grade for a class: 'auto' (a syntactic-local adapter covers it) | 'suggested' (a
    located-suggestion adapter) | 'none'. GRADED, never a gap — a class with no SolutionAdapter is surfaced
    'none', never a CI failure (resolution gated HARDER: absence is honest, not blocking)."""
    rungs = {s.get("rung") for s in solutions if cls in solution_classes(s)}
    if "syntactic-local" in rungs:
        return "auto"
    if "located-suggestion" in rungs:
        return "suggested"
    return "none"
```

- [ ] **Step 4: Thread `solutions` through `build()` + replace the resolution placeholder + carry provenance**

In `ledger.py`, change the `build` signature (line 88) from:

```python
def build(cells, detectors, confirmed, classes):
```
to:
```python
def build(cells, detectors, confirmed, classes, solutions=()):
```

Then replace the row dict's resolution line (lines 119-121) — from:

```python
            # resolution = class-level "how to fix" (§5). SolutionAdapter not built → surface the requires_resolution
            # flag honestly, never fabricate a rung.
            "resolution": "requires-resolution" if c.get("requires_resolution") else "n/a (no SolutionAdapter)",
```
to:
```python
            # resolution = class-level "how to fix" (§5), DERIVED from solution.json manifests (auto|suggested|none).
            "resolution": resolution_grade(cls, solutions),
            "provenance": c.get("provenance") or {},   # Discovered record (plain data); {} when absent
```

- [ ] **Step 5: Render the lifecycle table in `_md()`**

In `ledger.py`, in `_md()` (lines 177-186), insert before the final `return "\n".join(out)` (after the gaps block, line 185):

```python
    out += ["", "## Lifecycle (one row per defect)",
            "| defect | class | discovered | re-discovery | resolution | prevention |",
            "|---|---|---|---|---|---|"]
    for r in reg["defects"]:
        prov = r.get("provenance") or {}
        discovered = prov.get("audit") or prov.get("discovered_by") or r.get("fix_sha") or "—"
        redet = ", ".join(r["detectors"]) or "—"
        prevention = "ratchet" if r["ratcheted"] else "none"
        out.append(f"| {r['defect']} | {r['class']} | {discovered} | {redet} | {r['resolution']} | {prevention} |")
```

- [ ] **Step 6: Load solutions in `main()` + pass to `build()`**

In `ledger.py` `main()`, after line 195 (`detectors, skipped = manifest.load(ROOT)`), add:

```python
    solutions, sol_skipped = manifest.load(ROOT, kind="solution")
```
Change the `build(...)` call (line 196) from:
```python
    reg = build(cells, detectors, load_confirmed(), taxonomy_classes())
```
to:
```python
    reg = build(cells, detectors, load_confirmed(), taxonomy_classes(), solutions)
```
Then, immediately after the existing `if skipped:` block (after line 201), surface malformed solution manifests as a NOTE (graded, never a hard gap):

```python
    if sol_skipped:  # a malformed solution.json under-grades resolution — surface it, but resolution is GRADED not gated
        reg["summary"]["skipped_solutions"] = [p for p, _ in sol_skipped]
```

- [ ] **Step 7: Run to verify it passes (+ existing ledger tests green)**

Run: `python3 -m pytest tests/test_ledger_lifecycle.py tests/test_ledger.py -v`
Expected: PASS (7 new + existing test_ledger green — `build()` default `solutions=()` keeps positional callers safe)

- [ ] **Step 8: Smoke the real projection**

Run: `python3 ledger.py --check && python3 ledger.py --md | grep -A3 "Lifecycle"`
Expected: exit 0; the lifecycle table header prints with real defect rows.

- [ ] **Step 9: Commit**

```bash
git add ledger.py tests/test_ledger_lifecycle.py
git commit -m "feat(ledger): one-time lifecycle extension — solution_classes/resolution_grade + Discovered/Resolution columns (graded, no new gap)"
```

---

### Task 6: prove "add-a-bug = register" E2E on S4

**Wave:** 4
**Blocks:** Task 7
**Blocked by:** Task 3 (real S4 adapter), Task 4 (provenance), Task 5 (lifecycle ledger)

**Files:**
- Test: `tests/test_addabug_register.py`

- [ ] **Step 1: Write the E2E test**

```python
# tests/test_addabug_register.py
import importlib.util, os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _load(name, path):
    s = importlib.util.spec_from_file_location(name, path); m = importlib.util.module_from_spec(s); s.loader.exec_module(m); return m
cov = _load("sg_ledger_e2e", os.path.join(ROOT, "ledger.py"))

def _real_registry():
    cells = cov.bench.load_cells()
    detectors, _ = cov.manifest.load(ROOT)
    solutions, _ = cov.manifest.load(ROOT, kind="solution")   # discovers sql-parameterize from disk — no name in code
    return cov.build(cells, detectors, cov.load_confirmed(), cov.taxonomy_classes(), solutions)

def _s4_row(reg):
    rows = [r for r in reg["defects"] if r["defect"] == "S4-kb-spaceids-sqli"]
    assert len(rows) == 1, "exactly one S4 defect row"
    return rows[0]

def test_s4_full_lifecycle_with_honest_empties():
    row = _s4_row(_real_registry())
    assert row["provenance"].get("discovered_by") == "harvest"   # Discovered ✓
    assert "baseline" in row["detectors"]                        # Re-discovery = band-1 (baseline floor)
    assert row["resolution"] == "suggested"                      # Resolution = located-suggestion adapter
    assert row["ratcheted"] is False                             # Prevention = none (confirmed.json empty) — HONEST empty

def test_register_not_rewrite_no_hardcoded_defect_in_ledger():
    src = open(os.path.join(ROOT, "ledger.py"), encoding="utf-8").read()
    assert "sql-parameterize" not in src and "S4-kb" not in src  # the row is DERIVED from manifests, never named in code
```

- [ ] **Step 2: Run to verify it passes**

Run: `python3 -m pytest tests/test_addabug_register.py -v`
Expected: PASS (2 passed). If `test_s4_full_lifecycle_with_honest_empties` fails on `"baseline" in row["detectors"]`, that is a REAL finding (the baseline detector does not cover S4) — surface it, do not weaken the assertion.

- [ ] **Step 3: Commit**

```bash
git add tests/test_addabug_register.py
git commit -m "test(e2e): add-a-bug=register — S4 row derived (Discovered/band-1/suggested/Prevention=none), zero hardcoding"
```

---

### Task 7: green-gate verification + finalize

**Wave:** 5
**Blocks:** —
**Blocked by:** Task 1-6

**Files:** — (runs the aggregated dev gate; no new files)

- [ ] **Step 1: Run the full aggregated dev gate**

Run: `./check.sh`
Expected: exit 0 — pytest (all suites incl the 5 new) + `ledger.py --check` + `bench.py --routing` all green. Resolution is graded → no new hard gap.

- [ ] **Step 2: Confirm the lifecycle projection renders the worked example**

Run: `python3 ledger.py --md | sed -n '/Lifecycle/,/S4-kb-spaceids-sqli/p'`
Expected: the lifecycle table prints, and the S4 row shows `discovered=harvest` (or fix_sha), `re-discovery` includes `baseline`, `resolution=suggested`, `prevention=none`.

- [ ] **Step 3: Final verification of no-regression on the broader suite**

Run: `python3 -m pytest tests/ -q`
Expected: PASS (no regressions — `build()`/`load()` defaults preserved every existing caller).

- [ ] **Step 4: Merge the worktree back**

```bash
cd ~/Projects/security-gate
git checkout master
git merge --no-ff bug-registry-primitives -m "feat: bug-registry lifecycle + first SolutionAdapter (S4 sql-parameterize)"
git worktree remove .worktrees/bug-registry-primitives
```

---

## Architecture Decisions (inherited from spec)

- Registry = derived `ledger.py` projection EXTENDED (one-time), never a new hand-maintained store (ARCHITECTURE §5).
- One new primitive only: `SolutionAdapter` (passes the ≥2-adapter single-adapter test; deep — consumes the finding, internals replaceable). `RegistryEntry`/`ProvenanceRecord`/`PreventionAdapter` collapsed (failed the deletion/single-adapter test).
- `provenance` = plain cell data; Prevention = existing ratchet + detectors. No Detector contract change.
- Resolution is GRADED (`auto|suggested|none`), never a CI hard gap — absence is surfaced honestly.
- First adapter exercises the `located-suggestion` rung (S4 fix is semantic; auto-apply gated to syntactic-local — out of scope this cycle).

## Self-Review

**1. Spec coverage:** (1) `contract.resolution()` → Task 1 ✓. (2) `registry.load(kind=)` → Task 2 ✓. (3) S4 SolutionAdapter consumes-finding + specific suggestion + abstain/negative conformance → Task 3 ✓. (4) cell `provenance` schema → Task 4 ✓. (5) one-time ledger extension (solution_classes + Discovered/Resolution columns + grade + --md) → Task 5 ✓. (6) add-a-bug=register E2E with honest empties → Task 6 ✓. (7) check.sh/ledger --check green → Task 7 ✓.

**2. Placeholder scan:** No TBD/TODO. Every code step has full code; every command has expected output. The S4 `provenance` omits `audit`/`repo` by DESIGN (not a placeholder — documented no-fabrication).

**3. Type consistency:** `resolution(adapter, cls, rung, location, suggestion, patch=None, status=None, unresolved=())` defined Task 1, called identically in Task 3 (`contract.resolution(ADAPTER, CLS, "located-suggestion", loc, ...)`). `load(domains_root, kind=...)` defined Task 2, called `manifest.load(ROOT, kind="solution")` in Tasks 5/6. `build(..., solutions=())` defined Task 5, called with 5 args in Task 6. Row keys `resolution`/`provenance`/`ratcheted`/`detectors` produced in Task 5, read in Tasks 5/6 `_md` + E2E. Consistent.

**4. Wave plan check:** Every task has Wave/Blocks/Blocked-by. Wave 1 (Tasks 1,2,4) — `prevent/contract.py` / `prevent/registry.py` / `domains/.../corpus/*` — zero file overlap ✓. Waves 2-5 single-task. Task 3 blocked-by 1 (different wave) ✓. Task 5 blocked-by 2 ✓. Task 6 blocked-by 3,4,5 ✓. No same-wave file collision.
