# Ticket #000027 — Canonical projections persist to providence_cache

**Status:** closed · landed 2026-05-09
**Opened:** 2026-05-08
**Scope:** Treat `canonical_projection` results (arithmetic@v1, logic-kernel@v1,
and future algebra/calc kernels) as first-class providence records.
Cache lookup before kernel execution; kernel result writes a row plus
a `providence_canonical` audit event; second call to the same question
hits the cache.
**Audience:** fox + future blackops shifts.
**Hard constraint:**
- No new columns.
- No new tables.
- 8-dimensional cache_key invariant preserved.
- Existing CHECK constraints MUST be widened to admit:
  - `audit_mode = 'CANONICAL_PROJECTION'`
  - `verifier_method = 'canonical_projection'`

(Original draft said "no schema changes / no new admissibility
mode" — corrected 2026-05-09 per the response review §4.1 and the
implementation appendix §9.1. The CHECK widening is a value-space
extension, handled by the existing `_rebuild_providence_cache_*`
migration pattern — not a column/table add. See §9.1 for the five
locations in `arborist/store.py` that need the new tokens.)

---

## 1. Problem statement

`_canonical_projection_preflight` (`arborist/qa/query.py:3315`) is the
deterministic fast path for pure-arithmetic and pure-propositional
questions. Today the path is **transient compute**:

```
question → kernel → answer → return
                    │
                    └─ no cache lookup
                       no row written
                       no audit event
                       no run_dag
```

Concretely (from `arborist/qa/query.py:1999-2027`):

```python
canonical_match = _canonical_projection_preflight(question)
if canonical_match is not None:
    pi_star_ref, canonical_bytes = canonical_match
    answer_text = canonical_bytes.decode("utf-8", errors="replace")
    return {
        "status": "canonical_projection",
        "lookup_path": "preflight_canonical",
        "cache_key": None,                      # ← nothing persisted
        "sources": [],
        ...
    }
```

### 1.1 Concrete consequences

| Property | Today | Desired |
|---|---|---|
| Audit event written | No | Yes (`providence_canonical`) |
| `make stats` counts canonical answers | No | Yes |
| `make analyze-shards` includes them | No | Yes |
| `make chain-check-shards` covers them | Vacuously (none exist) | Yes |
| Re-asking same question | Re-runs kernel (~10ms) | Cache hit (~1ms) |
| Kernel-version migration (`@v1` → `@v2`) | Old answers vanish silently | Old rows go `stale`, replay-able |
| Audit replay for canonical answers | Impossible | Standard providence path |

Cost-of-recompute is irrelevant for `arithmetic@v1` (~10ms). The
motivation is **provenance discipline**: every answer arborist hands
back, regardless of source (LLM-text, kernel-projected), should be
discoverable, replayable, and falsifiable through the same audit
chain. Canonical projections are answers too.

### 1.2 Why this lands before the algebra/calc kernels

Algebra/calc projectors (`docs/tickets/ticket-NNN-algebra-projector.md`,
not yet opened) will be slower (50ms-2s for nontrivial SymPy) and
more numerous (a single library can grow to dozens of kernels). The
caching pattern needs to exist before that pressure arrives. Doing
arithmetic@v1 first is the small clean cut that establishes shape;
later kernels inherit it for free.

---

## 2. Design choices

### 2.1 Where do canonical rows live?

**A. Reuse `providence_cache` (RECOMMENDED).** Same table, same chain,
same admissibility ledger. Distinguish via `verifier_method =
"canonical_projection"` and `audit_mode = "CANONICAL_PROJECTION"`.
No schema migration.

**B. New `canonical_cache` table.** Lighter columns (no merkle_proof,
no n_quotes, no sources). Cleaner read pattern. **But** splits the
audit story — `chain-check-shards` would need a second pass; replay
tooling forks. Cost > benefit at current scale.

**C. Stay transient.** Reject — the entire ticket exists to fix this.

→ **Recommendation: A.** The schema already accommodates a row with
`merkle_proof = '{"context_root": null, "sources": []}'` and zero
`n_quotes` semantics adjusted (see §3.4).

### 2.2 8-dim cache_key composition for canonical rows

The cache_key dimensions are designed for RAG. Three of them
(`source_root`, `model_profile_hash`, `conversation_hash`) don't
naturally apply to canonical projections. **Synthetic but
deterministic** values bound to `pi_star_ref` keep the dimensionality
intact and let kernel-version migrations work via the existing
falsification pipeline.

| Dimension | Canonical value | Reasoning |
|---|---|---|
| `source_root` | `sha256("pi_star_source:" + pi_star_ref)` | Kernel IS the source. All `arithmetic@v1` rows share this root. Bumping to `arithmetic@v2` mints a new root → old rows become reachable via different cache_key but visible to audit replay. |
| `question_hash` | real, mode-aware | Same as RAG path. dedup mode (`strict` vs `equivalence_class`) stays meaningful. |
| `model_profile_hash` | `sha256("pi_star_model:" + pi_star_ref)` | No LLM, but the kernel acts as a deterministic "model." Per-kernel namespace. |
| `conversation_hash` | `sha256("pi_star_conv:" + canonical_question + ":" + pi_star_ref)` | No conversation; bind question canonical form so `"0.1+0.2"` and `"0.1 + 0.2"` route to the same conversation_hash if their `canonical_question` collapses identically. |
| `governance_policy_hash` | real | Folds in `canonical_projection_preflight` flag plus all standard policy fields. Flipping the master kill switch invalidates all canonical rows on next lookup. |
| `schema_version` | real | |
| `canonicalization_version` | real | |
| `chunking_version` | **literal `"n/a-canonical"`** | Chunking is irrelevant to arithmetic. Using the live `CHUNKING_VERSION` would mass-stale every canonical row whenever the wikipedia chunker changes (unrelated to math correctness). The sentinel string never collides with real chunk-version names. |

### 2.3 Cache hit behavior

When the cache_key matches a `live` row:

**A. Trust the row (RECOMMENDED).** Return persisted `answer_text` +
canonical_bytes. Increment `hit_count`. Do not re-run the kernel.
Same discipline as RAG cache hits. ~1ms (one SQL roundtrip).

**B. Re-run kernel and cross-check.** Defensive: run the kernel,
compare bytes to the row. Catches kernel-drift bugs at hit time but
costs the full ~10ms anyway. Effectively turns hits into
miss-with-validation.

**C. Policy-gated re-run.** Add `canonical_verify_on_hit` flag
(default False). Operators can enable for paranoid mode / kernel
investigations.

→ **Recommendation: A** for now. The kernel is content-addressed via
`pi_star_ref` and the kernel registry pins `arithmetic@v1` exactly;
re-running provides no audit value the existing version-pin doesn't
already provide. C is cheap to add later if a real need surfaces; A
keeps the hot path fast and the discipline uniform with RAG.

### 2.4 What `providence_canonical` audit event captures

```json
{
  "pi_star_ref": "arithmetic@v1",
  "question_text": "0.1 + 0.2",
  "canonical_input_sha256": "...",
  "canonical_output_bytes_hex": "..." ,
  "canonical_output_text": "3/10",
  "kernel_audit_mode": "CANONICAL_PROJECTION"
}
```

- `canonical_input_sha256` hashes the question after the kernel's
  internal canonicalization (whitespace folding, decimal normalization),
  so `"0.1+0.2"` and `"0.1 + 0.2"` produce the same value when the
  kernel canonicalizes them identically.
- `canonical_output_bytes_hex` pins the exact answer bytes — replay
  must produce the same bytes or the kernel has drifted.

### 2.5 Run-DAG shape for canonical projections

3 stages, mirrors RAG structure but trimmed:

```
question  →  canonical_projection  →  final_label
              │
              ├─ pi_star_ref
              ├─ canonical_input_sha256
              └─ canonical_output_sha256
```

Pinned via `run_dag_root` + `run_dag_blob` columns (already exist).
~200 bytes per row. Audit replay reads `run_dag_blob`, re-runs the
kernel from `pi_star_ref + canonical_input`, asserts
`canonical_output_sha256` matches.

### 2.6 What changes vs. what doesn't

**Changes:**
- Canonical projections write to `providence_cache`.
- New audit event type `providence_canonical`.
- Second call to a canonical question hits cache.
- `make stats` / `analyze-shards` count canonical rows.

**Does NOT change:**
- `providence_cache` schema (no new columns; CHECK constraints
  are widened to admit the CANONICAL_PROJECTION + canonical_projection
  tokens via the existing rebuild-table migration pattern).
- 8-dim cache_key invariant.
- `audit_events` table schema or chain protocol.
- The 4-rung label ladder (CANONICAL_PROJECTION sits outside the
  STRICT/HYBRID/UNGROUNDED trichotomy and outside the lattice
  ladder — distinct admissibility class).
- RAG path (zero impact on quote / claim_lattice / claim_lattice_pointer).
- `arborist canon` (stays transient — direct one-shot probe; this
  ticket's persistence applies ONLY to the `arborist query`
  preflight path).

---

## 3. Implementation sketch

### 3.1 New module `arborist/qa/canonical_cache.py` (~120 LOC)

```python
def canonical_synthetic_source_root(pi_star_ref: str) -> str: ...
def canonical_synthetic_model_hash(pi_star_ref: str) -> str: ...
def canonical_synthetic_conversation_hash(
    canonical_question: str, pi_star_ref: str
) -> str: ...
def canonical_chunking_version() -> str:
    return "n/a-canonical"

def canonical_cache_key(
    *, question: str, pi_star_ref: str,
    policy: dict, mode: str = "equivalence_class",
) -> str: ...

def lookup_canonical(qa_conn, cache_key: str) -> sqlite3.Row | None: ...

def persist_canonical(
    qa_conn,
    *,
    cache_key: str,
    question: str,
    pi_star_ref: str,
    canonical_input_bytes: bytes,
    canonical_output_bytes: bytes,
    policy: dict,
    chain: str = "private",
) -> str:  # returns event_hash
    ...

def build_canonical_run_dag(
    question_hash: str,
    pi_star_ref: str,
    canonical_input_sha256: str,
    canonical_output_sha256: str,
) -> dict: ...
```

### 3.2 Edit `arborist/qa/query.py` canonical branch (~40 LOC delta)

```python
if canonical_match is not None:
    pi_star_ref, canonical_bytes = canonical_match
    ckey = canonical_cache_key(
        question=question, pi_star_ref=pi_star_ref,
        policy=policy, mode=primary_dedup,
    )
    qa_conn = connect(qa_db)
    try:
        cached = lookup_canonical(qa_conn, ckey)
        if cached is not None:
            progress.emit("cache.hit", lookup_path="canonical")
            increment_hit_count(qa_conn, ckey)
            return _build_result_from_cached_canonical(cached)
        progress.emit("cache.miss", path="canonical")
        event_hash = persist_canonical(
            qa_conn,
            cache_key=ckey,
            question=question,
            pi_star_ref=pi_star_ref,
            canonical_input_bytes=question.encode("utf-8"),
            canonical_output_bytes=canonical_bytes,
            policy=policy,
            chain=chain,
        )
        return _build_result_from_fresh_canonical(...)
    finally:
        qa_conn.close()
```

### 3.3 Tests `tests/qa/test_canonical_cache.py` (~120 LOC)

- `test_first_call_writes_row` — INSERT + audit event present.
- `test_second_call_hits_cache` — `hit_count` goes 0 → 1; kernel
  not re-run (verify via patched `arithmetic@v1.canonicalize`).
- `test_chain_integrity_after_canonical_writes` — chain-check
  reports 0 breaks.
- `test_kernel_version_migration` — bump pi_star_ref from `@v1` to
  `@v2`; old row stays in DB but lookup misses (different
  source_root → different cache_key); new row written.
- `test_burn_drops_canonical_row` — `--burn` works on canonical too.
- `test_chunking_version_bump_does_not_stale_canonical` — bump
  CHUNKING_VERSION; canonical lookup still hits (uses the
  `"n/a-canonical"` sentinel).
- `test_distinct_pi_star_refs_namespace_separately` — same question
  through two different kernels → two distinct rows.
- `test_question_dedup_strict_vs_equivalence_class_for_canonical` —
  strict mode keeps `"0.1+0.2"` and `"0.1 + 0.2"` distinct;
  equivalence_class collapses them (depends on `canonical_question`
  collapsing whitespace — verify behavior matches RAG path).

### 3.4 Row column semantics for canonical rows

Setting choices for the existing `providence_cache` columns:

| Column | Canonical row value | Notes |
|---|---|---|
| `cache_key` | full 8-dim, computed | |
| `source_root` | synthetic (§2.2) | |
| `document_uri` | `f"pi_star://{pi_star_ref}"` | Human-readable; not part of cache_key. |
| `question_text` | original question, verbatim | |
| `answer_text` | `canonical_bytes.decode("utf-8")` | What the user sees. |
| `merkle_proof` | `'{"context_root": null, "sources": []}'` | No context. |
| `model_profile_hash` | synthetic (§2.2) | |
| `conversation_hash` | synthetic (§2.2) | |
| `governance_policy_hash` | real | |
| `schema_version` | real | |
| `canonicalization_version` | real | |
| `chunking_version` | `"n/a-canonical"` | |
| `falsification_state` | `"live"` | |
| `chain` | `"private"` | |
| `audit_event_hash` | from `append_audit` | |
| `created_at` | `int(time.time())` | |
| `hit_count` | 0 (UPDATE on hit) | |
| `audit_mode` | `"CANONICAL_PROJECTION"` | |
| `n_quotes` | `1` | Kernel output IS the one "quote" — itself. |
| `n_verified` | `1` | Kernel-verified by definition. |
| `unverified_quotes` | `NULL` | |
| `verifier_method` | `"canonical_projection"` | |
| `run_dag_root` | from `build_canonical_run_dag` | |
| `run_dag_blob` | from `build_canonical_run_dag` | |

### 3.5 CLI surface (no new flags)

`--burn` already operates on cache_key — works on canonical rows
unchanged. `--json` includes the full record. `make stats` will
naturally pick up canonical rows once they exist (no edit).

### 3.6 Progress emission (no new wiring)

The state-machine emitter (`arborist/qa/progress.py`) already fires
`cache.hit` / `cache.miss` for the RAG path. The canonical path
emits the same line names with `lookup_path="canonical"`. Operators
see:

```
[arborist 0.01s] preflight.start mode=claim_lattice
[arborist 0.02s] canonical_projection.match pi_star_ref=arithmetic@v1
[arborist 0.02s] cache.hit lookup_path=canonical ms=1
```

vs. fresh:

```
[arborist 0.02s] canonical_projection.match pi_star_ref=arithmetic@v1
[arborist 0.02s] cache.miss path=canonical
[arborist 0.03s] kernel.run pi_star_ref=arithmetic@v1
[arborist 0.04s] persist.done ms=12
```

---

## 4. Out of scope

- **Cache by canonical-bytes-of-input** so `"0.1+0.2"` ≡ `"0.2+0.1"`
  ≡ `"1/10 + 2/10"` share one row. Requires question-level
  canonicalization that produces the kernel's input bytes — a
  separate transformation from question_hash's text canonicalization.
  Phase 2 ticket; the dedupe is nice-to-have, not load-bearing.
- **Algebra / calc / SymPy kernels.** Each gets its own ticket. This
  ticket establishes the persistence pattern they inherit.
- **Backfill.** Prior canonical-projection calls were stateless; no
  rows exist to import.
- **Cross-shard canonical sharing.** Canonical rows live in the
  `qa.db` providence_cache same as RAG rows. No cross-shard
  replication.
- **Falsification-on-drift for canonical rows.** Kernel output is
  deterministic by construction; drift would mean a kernel bug, not
  data drift. Treated by version-bump migration (`@v1` → `@v2`),
  not in-place falsification. Existing `falsification_state =
  "stale"` mechanic stays available but isn't expected to fire in
  normal operation.

---

## 5. Risks and rollback

**Risk 1 — schema lookup widening.** Adding canonical rows to
`providence_cache` means existing queries (`SELECT * FROM
providence_cache WHERE ...`) now return canonical rows alongside
RAG rows. Audit dashboards and stats commands that don't filter on
`audit_mode` may double-count. Mitigation: audit
`arborist/cli.py` callers; add `audit_mode != 'CANONICAL_PROJECTION'`
filter where RAG-only counts are intended (likely zero call sites
since canonical rows didn't exist).

**Risk 2 — chain-check-shards regression.** New audit event type
joins existing chain. If `append_audit` rejects or mishandles the
new `event_type`, chain breaks. Mitigation: `event_type` is an
opaque string in the existing chain protocol; no enumeration to
update.

**Risk 3 — `make stats` reporting confusion.** Canonical rows
mixing with RAG rows changes the meaning of "average n_verified"
etc. Mitigation: stats output should break out a canonical column
once rows are present (~5 LOC delta in cli.py:_cmd_stats; treat
as in-scope).

**Rollback**: set `policy["canonical_projection_preflight"] =
False` to disable the path entirely. Existing canonical rows
remain readable; new questions skip the canonical path and go
through RAG.

---

## 6. Dependencies and prior tickets

- Builds on existing `arborist/pi_star/arithmetic.py` and
  `arborist/pi_star/logic.py` kernels (pinned to `@v1`).
- Reuses `arborist/qa/dag.py` `_canonical_json` + `_sha256_hex`
  helpers for the canonical run-DAG.
- Reuses `arborist/store.py:append_audit` chain protocol unchanged.
- Reuses progress emitter from the 2026-05-08 state-machine wiring.

No conflicts with open tickets.

**Forward link:** #000028 (multi-witness canonical agreement)
proposes a calibration / witness layer that runs ON TOP of this
ticket's persisted cache. #000028 explicitly depends on this
ticket landing first; it is NOT a Phase 2 of #000027 — it has its
own scope, threat model, and acceptance criteria. Keeping the
two separate prevents the persistence primitive from being
contaminated with LLM-fan-out concerns.

---

## 7. Acceptance criteria

1. `make query Q="0.1 + 0.2"` writes one `providence_cache` row +
   one `providence_canonical` audit event on first call.
2. Same query on second call: `cache.hit lookup_path=canonical`,
   `hit_count` goes 1 → 2 (counting includes the second hit).
3. `make chain-check-shards` reports 0 breaks per shard after a
   batch of mixed canonical + RAG queries.
4. `make analyze-shards` displays canonical row counts in the
   audit-mode breakdown.
5. New tests in `tests/qa/test_canonical_cache.py` pass.
6. Existing tests in `tests/test_query.py` and the broader suite
   pass unchanged (no RAG regression).
7. Bumping `pi_star_ref` from `arithmetic@v1` to `arithmetic@v2`
   (test scaffold) routes new questions to a fresh row; old `@v1`
   row remains queryable but unreachable via the live cache_key.

---

## 8. Open questions

1. **`chunking_version`**: literal `"n/a-canonical"` (recommended,
   §2.2) vs. live `CHUNKING_VERSION` (mass-stales canonical rows
   on chunker bumps) vs. NULL (schema-incompatible).
2. **Cache hit verification**: trust the row (recommended, §2.3)
   vs. re-run kernel always vs. policy-gated re-run.
3. **`make stats` breakout**: include the ~5 LOC to break out
   canonical rows from RAG counts in this ticket, or defer?
4. **Question-level canonicalization for cache_key**: does
   `equivalence_class` mode strip whitespace such that `"0.1+0.2"`
   and `"0.1 + 0.2"` already collide via `question_hash`? If yes,
   no further work needed for surface-form dedup. If no, the
   `conversation_hash` synthetic value (which uses the canonical
   question form) carries the dedup; verify the mode behavior
   before relying on it.

---

## 9. Implementation context appendix

Added 2026-05-08 (post-Phase-1+2 of #000026) — concrete pointers
for the hands-on-keyboard pass.

### 9.1 The hard constraint claim needs amendment

Header reads "No schema changes. No new admissibility mode (the
existing `audit_mode = 'CANONICAL_PROJECTION'` already
round-trips)." That is not strictly accurate today: the
`audit_mode` and `verifier_method` columns each have a CHECK
constraint that the new tokens do NOT satisfy. Inserting a
canonical row would fail at SQLite's constraint enforcement.

Concrete state in `arborist/store.py`:

| Location                              | Current CHECK                                        |
|---------------------------------------|------------------------------------------------------|
| `SCHEMA_SQL` line 177                 | `audit_mode IN ('STRICT','HYBRID','UNGROUNDED')`     |
| `SCHEMA_SQL` line 193                 | `verifier_method IN ('quote','span','entity','paraphrase','claim_lattice','none')` |
| `_migrate_audit_mode` line 560        | rebuild path's CHECK string (matches above)          |
| `_migrate_audit_mode` line 578        | `verifier_method IN ('quote','span','entity','paraphrase','none')` |
| Lines 920, 998, 1003, 1070            | three more rebuild-path CHECK strings to update      |

Two reads of the constraint:

- **Strict reading**: this ticket DOES require a schema migration
  (extend both CHECKs), even though no new columns are added.
  Schema-migration helpers `_rebuild_providence_cache_*` already
  exist for prior CHECK extensions; reuse the pattern.
- **Loose reading**: "no schema changes" means "no new
  columns / no new tables." The CHECK extension is a value-space
  widening that the existing migration framework handles. This is
  the spirit fox probably intended.

Either way: **the implementation must extend both CHECKs**, and the
PR description should call that out so the loose-reading
interpretation isn't misread as "skip the migration."

Recommend updating §2.6 ("Does NOT change") to clarify:

> - `providence_cache` schema (no new columns; CHECK constraints
>   are widened to admit CANONICAL_PROJECTION + canonical_projection
>   tokens via the existing rebuild-table migration pattern).

### 9.2 Pointer freshness

| Reference in ticket          | Location today              |
|------------------------------|------------------------------|
| `arborist/qa/query.py:3315`  | confirmed (still 3315 as of `c54beda`) |
| `arborist/qa/query.py:1999-2027` | drifts as the file grows; pin to the function name `query()` body, search for `canonical_match = _canonical_projection_preflight` |

Pin to function names rather than line numbers in future tickets;
cli.py / query.py drift fast.

### 9.3 Test layout

Ticket §3.3 names `tests/qa/test_canonical_cache.py`. The repo's
test directory is flat — `tests/qa/` does not exist. Other
QA-domain tests use names like `tests/test_query.py`,
`tests/test_canonical_projection.py`. Recommend
`tests/test_canonical_cache.py` to match convention.

### 9.4 Existing test the new work must not break

`tests/test_canonical_projection.py` (landed 2026-05-08, commit
`0a2e347`) currently asserts `cache_key is None` and
`status == "canonical_projection"` for the preflight path.
Persistence will change both:

- `cache_key` becomes a real value.
- `status` flips between `"cache_miss_then_written"` (first call)
  and `"cache_hit"` (subsequent calls), matching the RAG path.

Two options:
- **Update the existing tests** to assert the new contract. Simpler.
- **Keep the existing tests as a "transient mode" assertion** with
  `policy["canonical_projection_preflight_persist"] = False` (new
  policy field that gates persistence independently from the
  preflight itself). Useful if operators want the canonical short-
  circuit but explicitly don't want audit-chain bloat for math
  questions.

Recommend the second — adds one policy field for ~10 LOC and gives
operators a kill-switch that doesn't disable the whole preflight.
Defaults to True (persist), preserving the ticket's main intent.

### 9.5 governance_policy_hash bump

Adding `canonical_projection_preflight` (already exists) plus the
new `canonical_projection_preflight_persist` field (per §9.4)
folds two flags into governance_policy_hash. Every existing
provicence_cache row's cache_key will compute differently on next
lookup → cache misses for one cycle until rewritten. This is the
standard contract for new policy fields, but call it out in the
commit message so it's not mistaken for a regression.

### 9.6 Mesh broadcast implication

Once canonical rows persist, mesh-sync will broadcast them like
any other providence row. Two things to check before flipping the
switch in mesh-deploy:

1. The new `audit_mode` token reaches mesh-side validators that
   may have their own CHECK or enum (out-of-tree clients).
2. Bandwidth: math/logic questions are tiny (~200 bytes per row);
   not a bandwidth concern unless an operator runs a sweep.

Suggest adding a §10 "Mesh-side compatibility" sub-ticket if
fox sees out-of-tree clients with strict enums.

### 9.7 `arborist canon` interaction (per §8 question 2 expanded)

The `arborist canon <key> "<input>"` subcommand also reaches the
π* registry but bypasses `arborist query` entirely. It does NOT
need persistence — it's a one-shot probe surface, deliberately
transient. The ticket's persistence path applies ONLY to
`arborist query` / `make query`. Recommend explicitly stating
this in §1 or §2.6; otherwise a future maintainer might add
`canon`-side persistence by symmetry and bloat the cache.

### 9.8 Estimated diff size (revised)

Original §3 estimate:
- `arborist/qa/canonical_cache.py` — 120 LOC
- `query.py` delta — 40 LOC
- tests — 120 LOC
- **total ~280 LOC**

With §9.1 (schema migration) + §9.4 (policy gate) + §9.7 docs:
- `arborist/store.py` migration helper — ~40 LOC + ~15 LOC SCHEMA_SQL
- policy field plumbing — ~15 LOC across policy default + governance hash + CLI flag
- existing test updates — ~10 LOC delta to `test_canonical_projection.py`
- **revised total ~360 LOC**

Still single-commit feasible. If the schema migration ends up
larger (legacy CHECK rebuild quirks), split into:
- (a) CHECK extension + migration tests  [~80 LOC]
- (b) cache-key minting + persist path + tests  [~280 LOC]
