# Reference-Necessity Task 14 — Owned-Prop Namespace Overlap Fix Wave (addendum)

- **Parent plans**:
  - `docs/superpowers/plans/2026-05-23-reference-necessity-phase012-implementation.md` (v3 + erratum)
  - `docs/superpowers/plans/2026-05-23-reference-necessity-task14-generic-descriptor-fix-wave.md` (Phase 3 addendum)
- **Status**: open. branch `feat/reference-necessity-phase012` HEAD `ba52a69` (unpushed). origin/main `a3942ee`.
- **Trigger**: Task 14 acceptance reverify (project `6cb862d9-590c-4dce-86e6-d10c2977db19` / episode `08ad2cd3-3e96-4d84-808f-869ee628473c`) closed Phase 3 generic-descriptor invariant (S1_Shot1/S1_Shot4 PASS) but stopped on `S28_Shot1` `owned_validation` redraw_violation, surfacing an orthogonal **setup-level conflict**: visible prop `P06` (= "종이 지도") shares its referent with the `L17` background's owned token `'map'`. Both catalogs claim the same physical object. Phase 3 wave was concerned only with the id-policy axis and is unaffected.
- **Codex verdict (this wave entry sanity)**: not a Phase 3 regression, but blocks Task 14 push and must close on the same branch. Treat as a separate narrow consumer-boundary reconciliation wave.

## 1. Diagnosis evidence (read-only, captured)

- `projects/6cb862d9-590c-4dce-86e6-d10c2977db19/checkpoints/episodes/08ad2cd3-3e96-4d84-808f-869ee628473c/scene_detail/manifest.json` — `S28_Shot1` `visible_entities=['C01','C03','P06','L17']`. `representative_moment` = "해질 무렵의 어선 갑판에서 C01이 C03을 향해 **P06 종이 지도를 내밀고**, C03이 굳은 표정으로 지도를 내려다보는 순간." Variation `t2i_prompt` contains `"...thrusts P06, a creased paper map damp at the edges, forward across the air between them..."`. `owned_object_usage[map] = redraw`. `owned_validation.violations[1] = {owned_object:"map", verdict:"redraw_violation", reason:"...constituting a redraw of this background-owned asset."}`. Status = `contract_violation` after 2 owned_repair attempts. 60/61 shots clean.
- `backend/app/core/steps/_owned_judge.py:34` — `run_owned_judge(t2i_prompt, owned, owned_object_usage, ...)` is a thin LLM wrapper. Returns `violations: List[{owned_object, violation_evidence, verdict, reason}]`.
- `backend/app/core/steps/_owned_helpers.py`
  - `:240 merge_owned_object_usage` enforces `owned_token ⊆ normalized_owned_list` (no extras, no missings) and synthesizes `absent` entries when LLM omits some.
  - `:420 build_owned_sentinel` records `coverage_hash`, `owned_hash`, `owned_usage_hash`, `camera_direction_hash`, `t2i_prompt_hash`, and `violations` on the variation. **Hash inputs must be the original `owned` list and `owned_object_usage`** — recomputing with the original SOT is what `verify_completion` later compares against. Any pre-process that mutates these inputs forks verify-path drift.
  - `:511 assert_owned_sentinel_shape` — sentinel verdict enum is `{"redraw_violation", "anchor_reference"}`. No room for a third value without schema churn.
  - `:613 has_redraw_violation(violations)` — only counts entries whose `verdict == "redraw_violation"`. Anchor-reference entries pass.
- **Violation entry shape (v2 schema)**: `{owned_object: str, violating_phrase: str, verdict: "redraw_violation"|"anchor_reference", reason: str}`. The text key for the offending excerpt is `violating_phrase`, not `violation_evidence`.
- `backend/app/core/steps/detail_steps.py:3428` — main `_analyze_one()` calls `run_owned_judge()` then `has_redraw_violation()` then `_attempt_owned_redraw_repair()`. Repair internally runs `run_owned_judge()` again at `:2205`. Both paths feed `build_owned_sentinel()`.
- `backend/app/models/project.py:28-64` — prop entity is `EntityCanon(entity_type='prop', short_id='P##', name=<KO/EN>, description, t2i_prompt)`. `EntityAlias(canon_id, alias)` for aliases.
- `backend/app/core/steps/scene_context_loader.py:71-72, 379-446` — `ctx.name_by_short_id` / `ctx.traits_by_short_id` are prebuilt **for characters only**. No prop alias map is currently loaded into ctx; the loader prebuilds traits only for `entity_type='character'`.
- ThreadPool worker constraint (round 4 PRO-4, B1 carry): worker threads must not open SQLAlchemy sessions. Any DB-backed prop alias/name lookup must be prebuilt on the main thread by `SceneContextLoader` and handed in as a plain dict.

## 2. Design decision (Codex APPROVED, recorded here)

1. **Consumer-boundary verdict reclass**, not input pre-process and not upstream filter. A pure deterministic helper sits between `run_owned_judge()` and `has_redraw_violation()` on the main analyze path **and** inside `_attempt_owned_redraw_repair()` after its repair-verify `run_owned_judge()`. It receives the LLM-derived `violations` and reclassifies entries whose owned token is provably owned by an actively-used visible prop.
2. **Verdict β — `anchor_reference` reclass**, never drop. The reclassified entry retains `owned_object`, `violating_phrase`, and the original `reason` plus a deterministic suffix: `"Deterministic prop-owned namespace reconciliation: visible prop <P##> owns token <token>; prompt uses <P##> in the source phrase."` Coverage, `owned_object_usage`, `owned` list — all unchanged. Hash invariants and `verify_completion` SOT recompute therefore see the same inputs and the same final violations list. `build_owned_sentinel.violations` is the post-reclass list.
3. **Exact term match only — no fuzzy, no stemming, no synonyms, no translation inference**. The reconciliation needs a *prop term set*. For a visible prop `P##`, that set is the ASCII-lowercased word/phrase normalization of: `EntityCanon.name`, `EntityCanon.description`, `EntityCanon.t2i_prompt`, and each `EntityAlias.alias` row. **Producer owns token expansion; helper remains membership-only** (Codex 2026-05-23 decision after W2 mid-wave consult). `SceneContextLoader._load_entity_canon_prop_term_map` for each source string adds (a) the full normalized phrase **and** (b) the ASCII word-boundary content tokens of that phrase (i.e. `re.findall(r"[a-z0-9]+", normalized)` minus a small fixed stopword set: `a, an, the, of, at, in, on, to, with, and, or, for, from, by, into, over, under, across, between`). Korean / non-ASCII phrases are added as full phrases only — they are NOT tokenized. `reconcile_owned_prop_namespace_overlap` then does pure full-string membership against this expanded set; it never splits the prop terms it receives. Owned tokens (single- or multi-word) are normalized identically and tested for membership.
4. **All four gate conditions must hold** before any reclass happens:
   1. `P##` ∈ `visible_entities` for the shot.
   2. `P##` literal substring present in the variation's `t2i_prompt`.
   3. The owned token (the entry's `owned_object`) exactly matches one of the normalized terms in that prop's term set.
   4. *Source-phrase linkage*: either (a) the owned token's `owned_object_usage[*].source_phrase` for this token contains the `P##` literal, **or** (b) the violation's `violating_phrase` substring contains both the `P##` literal and the owned token within the same excerpt. If neither holds, the reconciliation does **not** fire for that entry, and the LLM's `redraw_violation` stands. Visible-prop presence alone is never sufficient.
5. **Prop term map is prebuilt on the main thread.** `SceneContextLoader` extends to load `entity_type='prop'` rows (`name`, `description`, `t2i_prompt`) joined with `EntityAlias.alias`, normalize once, and expose `ctx.prop_term_map: Dict[str, FrozenSet[str]]` keyed by `P##`. Threadpool workers receive it via the existing ctx-arg surface; no per-shot DB query.
6. **No schema migration**, **no new verdict enum**, **no prompt edit**, **no `background_master_plan` change**. Phase 3 v34 prompt and validator stay intact. Verdict enum stays `{redraw_violation, anchor_reference}` — `assert_owned_sentinel_shape` is unchanged.

## 3. Scope

### In-scope

- **W1 RED tests** — `backend/tests/unit/test_owned_prop_namespace_reconciliation.py` (new) covers the helper as a unit. Includes:
  - **G1 happy path (S28_Shot1 fixture analog)**: `visible_entities=[C01,C03,P06,L17]`, prop term map `{P06: {"종이 지도", "paper map", "map"}}`, owned token `"map"`, `t2i_prompt` mentions `P06` + "paper map", source_phrase contains `P06` → reclass to `anchor_reference`.
  - **G2 negative — no visible prop**: same prompt + owned token but `P06` not in `visible_entities` → no reclass, redraw stands.
  - **G3 negative — visible prop but P## not in prompt**: `P06` in `visible_entities` but `t2i_prompt` lacks the `P06` literal → no reclass.
  - **G4 negative — no term overlap**: `P06` term set lacks "map" (different prop) → no reclass.
  - **G5 negative — no source-phrase linkage**: `P06` and `map` both present in the prompt, but neither `source_phrase` nor `violation_evidence` co-locates them → no reclass.
  - **G6 multi-word owned token**: owned `"oil lantern"` exact phrase match against prop term `"oil lantern"`; partial `"lantern"` alone does not match.
  - **G7 single-word owned token boundary**: owned `"map"` does not match prop term `"mapped territory"` (word boundary).
  - **G8 reason suffix invariant**: reclassed entry preserves original `reason` and appends the deterministic note exactly once; double-call idempotency.
  - **G9 sentinel hash invariance**: `build_owned_sentinel(... violations=reclassed)` yields the same `owned_hash`/`owned_usage_hash`/`coverage_hash` as the pre-reclass call would have, because the underlying `owned` list and `owned_object_usage` are untouched.
  - **G10 prebuild absence safe**: if `ctx.prop_term_map` is missing or the visible prop is not in the map (defensive), helper returns the original violations unchanged and emits no exception.
- **W2 implementation** — `_owned_helpers.reconcile_owned_prop_namespace_overlap(violations, owned_object_usage, t2i_prompt, visible_entities, prop_term_map)` pure function. `scene_context_loader.SceneContextLoader._load_entity_canon_character_maps` co-extended (or a sibling method) to also load `prop_term_map` and assign to `ctx`. `detail_steps._analyze_one()` calls the helper between `run_owned_judge(...)` and `has_redraw_violation(...)` (around line `:3428-3439`); `detail_steps._attempt_owned_redraw_repair()` calls the helper between `cand_violations = run_owned_judge(...)` and any has_redraw_violation gate inside the repair (around `:2205`). `_analyze_one()` passes `ctx.prop_term_map` to the helper; the repair path already has access to ctx fields via its existing closure.
- **W3 focused regression** — `cd backend && python -m pytest tests/unit/test_owned_prop_namespace_reconciliation.py tests/unit/test_owned_helpers.py tests/unit/test_subject_reference_policy_helper.py tests/core/test_visible_entities_validator.py tests/core/steps/test_render_prompt_card_episode_policy.py tests/test_prompt_versions.py tests/prompts/test_scene_detail_id_policy_alignment.py tests/services/test_reference_necessity_audit.py tests/core/steps/test_owned_helpers_close_skip.py tests/core/steps/test_owned_redraw_repair.py -v`. Phase 3 invariant fixtures must stay green. No new test failures vs the baseline at `ba52a69`.
- **W3.5 Codex narrow review** — Codex reviews this wave's commits only (subrange `ba52a69..HEAD`). On `APPROVED_FOR_W4_ENTRY`, proceed.
- **W4 same-episode reverify** — restart backend (Phase 3 + this wave code reflected); same project/episode; targeted `scene_detail` redo on `S28_Shot1` only (`force`, scope `scene_detail`); `verify_completion` must return clean; downstream `category=all&mode=resume` continues image phase.
- **W5 acceptance** — Phase 0 GATE `scripts/reference_necessity_audit` re-run on this episode (exit 0, `gate_blocking_ids 0`); §8.2 checklist `text_only subject t2i_prompt 에 C## 없음` re-verify on S1_Shot1/S1_Shot4 (Phase 3 invariant); `scene_image_pipeline` completes without `missing required ref`; `episode_reference_policy/manifest.json` 48 entries 3-class still valid; `ref_low_freq_skip.json` reasoned v2 spot-check.
- **W6 full range Codex review** — `origin/main..HEAD` (Phase 0+1+2+3+this wave). Both W3.5 narrow review and W6 final range review run because the user's standing rule routes all code review through Codex.
- **W7 push** — on Codex `APPROVED_FOR_PUSH`, push `feat/reference-necessity-phase012` per the user's standard merge/push protocol.

### Non-scope (explicit; Codex CONFIRMED)

- **No owned validator warn-only mode.** Validator stays fail-closed.
- **No `S28_Shot1` hardcode** anywhere — fixture or production.
- **No scenario-specific prompt patch.** Phase 3 v34 wording stands.
- **No manifest direct edit.** Reverify happens only via real `scene_detail` redo through the helper.
- **No partial downstream override** of `allow_partial_downstream`.
- **No fuzzy / stemming / synonym / translation matching** in the reconciliation. Exact normalized term / word-boundary / phrase only.
- **No verdict enum change.** No `prop_takeover` value. Reclass uses the existing `anchor_reference`.
- **No `background_master_plan` upstream filter** in this wave. (Future optimization.)
- **No `run_owned_judge` input pre-process.** owned list / owned_object_usage shape into LLM judge is unchanged so that sentinel coverage/hash invariants hold.
- **No `max_retry` bump or new redo round.** This is a deterministic fix; LLM nondeterminism is not the root cause.

## 4. Wave order and ownership

| Wave | Owner | Output |
| ---- | ----- | ------ |
| W0   | Main  | This addendum, task list, branch sanity. |
| W1   | Worker A (Opus) | RED unit tests for the helper (G1-G10). Failing import / assertion. |
| W2   | Worker B (Opus) | Helper in `_owned_helpers.py`, `SceneContextLoader` prop term prebuild, `detail_steps._analyze_one` + repair-verify wiring. Tests GREEN. |
| W3   | Main  | Focused regression pytest, baseline diff = 0 new failures. |
| W3.5 | Main + Codex | Codex narrow review on the wave subrange. APPROVED_FOR_W4_ENTRY gate. |
| W4   | Main  | Backend restart, `scene_detail` `S28_Shot1` targeted redo, verify clean, downstream `mode=resume`. |
| W5   | Main  | Acceptance checklist (Phase 0 GATE + §8.2 + Phase 3 invariant + image pipeline). |
| W6   | Main + Codex | Final full-range review `origin/main..HEAD`. |
| W7   | Main  | Push on `APPROVED_FOR_PUSH`. |

Workers A and B operate on disjoint write scopes (A: `backend/tests/unit/test_owned_prop_namespace_reconciliation.py` only; B: `backend/app/core/steps/_owned_helpers.py` + `backend/app/core/steps/scene_context_loader.py` + `backend/app/core/steps/detail_steps.py`). Neither reverts the other. Each leaves at least one commit and reports test counts.

## 5. Acceptance gates

1. **W1 gate** — `tests/unit/test_owned_prop_namespace_reconciliation.py` exists, defines G1-G10, and fails on import/assertion before W2 lands.
2. **W2 gate** — same test file passes 10/10. `_owned_helpers.reconcile_owned_prop_namespace_overlap` is a pure function (no I/O, no DB). `ctx.prop_term_map` is built once on the main thread. No mutation of `owned` list or `owned_object_usage`. Sentinel verdict enum unchanged.
3. **W3 gate** — full focused-scope pytest GREEN; no regression vs baseline at `ba52a69`.
4. **W3.5 gate** — Codex `APPROVED_FOR_W4_ENTRY` on the wave subrange.
5. **W4 gate** — `S28_Shot1` targeted redo completes with `status: completed`, `owned_validation.violations` either empty or all `verdict: anchor_reference`, manifest `dirty` count → 0. Downstream `scene_image_pipeline` completes 61/61 without `missing required ref`. No SQL repair, no manifest patch, no `allow_partial_downstream` override used.
6. **W5 gate** — Phase 0 GATE re-PASS (`gate_blocking_ids 0`). §8.2 invariant: `S1_Shot1` (C06) / `S1_Shot4` (C12) `t2i_prompt` still contain zero `C##`/`C##O##` tokens. `episode_reference_policy/manifest.json` 48 entries unchanged. `ref_low_freq_skip.json` reasoned v2 shape spot-checked.
7. **W6 gate** — Codex `APPROVED_FOR_PUSH` on `origin/main..HEAD` full range.
8. **W7 gate** — `git push` succeeds; user's push protocol respected.

## 6. Codex consultation cadence

- W0 → this addendum is the design pin.
- W1/W2 each → no per-wave Codex approval if no scope drift; integrate into branch.
- W3.5 → mandatory Codex narrow review.
- W4 evidence → Codex read-only acceptance on the S28 manifest + downstream completion.
- W6 → final full-range review.
- Anytime: re-consult on scope drift (upstream filter, fuzzy match, DB alias loader expansion, verdict enum change, partial downstream override, manifest patch, push).

## 7. Risks / known unknowns

- **Prop term ambiguity.** Two visible props could legitimately share a term (e.g. two `P##` props both named `map`). Mitigation: reconciliation iterates all visible props per violation; first prop whose source-phrase gate fires wins. Reason note records the winning `P##`. If zero gates fire, original `redraw_violation` stands.
- **Korean term + romanization.** `EntityCanon.name` may be Korean (`종이 지도`) while owned tokens are ASCII (`map`). The prop term set therefore must include `description`, `t2i_prompt`, **and** `EntityAlias.alias` — ASCII normalization of Korean alone won't help. Codex confirmed: include all fields. If even this is insufficient to cover the S28 case at W4, the design fails and we re-consult; we do **not** add fuzzy match.
- **Repair-verify duplication.** The helper must run inside `_attempt_owned_redraw_repair` too. If only the main path is wired, repair will still revert. The W1 G-tests do not directly exercise repair flow; W4 same-episode S28 redo is the integration evidence.
- **Phase 3 invariant collision.** This wave does not alter id-policy axis. S1_Shot1 / S1_Shot4 must remain C##/C##O##-free post-W4. W5 verifies.

## 7a. Mid-wave decision (Codex 2026-05-23, after W2 first pass)

After Worker B's first W2 pass landed (commits `6076033..943b724`) and live Postgres confirmed P06 has zero EntityAlias rows + only Korean `name`/`description` + an English `t2i_prompt` whose "map" appears only inside `"...folded printed paper map..."`, the wave's matching contract was clarified:

- **Helper stays membership-only.** `reconcile_owned_prop_namespace_overlap` does NOT split prop terms. G6b ("`lantern`" vs `{"oil lantern"}` only → no reclass) and G7 ("`map`" vs `{"mapped territory"}` → no reclass via word-boundary semantics on the producer side) both remain valid as helper unit invariants.
- **Producer owns expansion.** `SceneContextLoader._load_entity_canon_prop_term_map` adds, for each ASCII source string, both the full normalized phrase AND each ASCII word-boundary content token (`re.findall(r"[a-z0-9]+", normalized)`) minus a small fixed stopword set. So `"creased paper map"` produces `{"creased paper map", "creased", "paper", "map"}` and `"mapped territory"` produces `{"mapped territory", "mapped", "territory"}` (no `"map"`).
- **Stopwords**: fixed `{a, an, the, of, at, in, on, to, with, and, or, for, from, by, into, over, under, across, between}`. Do not extend per-scenario. No length cutoff (a `"tv"` owned token must remain matchable).
- **Korean / non-ASCII phrases**: added as full phrases only. No tokenization on Korean. `re.findall(r"[a-z0-9]+", ...)` against a Korean string yields `[]`, so the producer simply skips token-expansion when no ASCII tokens are found.
- **Test split**: existing helper-unit tests keep their membership-only invariants. New producer-unit tests cover expansion behavior (full phrase, ASCII tokenization, stopword exclusion, Korean preservation). New integration-style fixture in helper-unit tests can exercise a producer-like expanded set if useful for clarity, but is not required.

## 7b. W4b sub-wave (Codex 2026-05-23, mid-W4 obstacle)

W4 reverify uncovered two distinct issues after S28_Shot1 successfully reclassed:
- **S18_Shot12** (visible=[P07] photo-frame) — LLM intent itself asks to "modify" the frame's interior (face melting downward). `violating_phrase` does not contain `P07` literal. Helper's gate 4(a) (source_phrase echo) could fire reclass non-deterministically based on LLM source-phrase declaration. This is a **false-pass risk**: real redraw intent could be hidden via source_phrase echo alone.
- **step_run projection-promote gap** — `scene_detail_redo_service` writes manifest as `status="completed"` but only touches `step_run.updated_at`. Existing partial row stays partial. `StepRunner._evaluate_resume_decision` then treats partial as `RERUN_SELF/prior_state` on the next `scene_detail?mode=resume`, triggering full 60-shot LLM re-execution and re-introducing fresh contract violations. This is also the root cause of the perceived "ctx hash drift" — actually the step_run partial state, not card-hash invariance.

### W4b scope

- **W4b-1 Gate 4 tightening (fail-closed)** — helper's gate 4 now requires `violating_phrase` literal substring to contain BOTH the matched `P##` AND the `owned_object` (case-insensitive, single substring). source_phrase echo alone is no longer sufficient. Implementation either drops the gate 4(a) branch or demotes it to supplementary-only-after-(b)-passes. S28 positive remains valid (its `violating_phrase` = "thrusts P06, a creased paper map damp at the edges, forward" contains both `P06` and `map`). S18 negative is forced (its `violating_phrase` lacks `P07`).
- **W4b-2 `scene_detail_redo_service` projection promote** — after manifest replace + checkpoint sync, the redo service invokes `SceneDetailStep.verify_completion(...)` and on clean result writes `step_run.status='completed'` + clears error_message + updates completed/applicable/failed counts. On dirty result, partial is preserved. This is not manual SQL repair; it is the redo service completing its normal projection.
- **W4b-3 RED tests** — extend `tests/unit/test_owned_prop_namespace_reconciliation.py` with an S18-shape negative (source_phrase has P##, violating_phrase does not — must not reclass). Extend `tests/services/test_scene_detail_redo_service.py` (or create) with clean-promote and dirty-no-promote unit tests against a fake verify_completion.
- **W4b-4 GREEN implementation** — narrow patch on `_owned_helpers.reconcile_owned_prop_namespace_overlap` and `app/services/scene_detail_redo_service.py`. No prompt edit, no schema migration, no verdict enum change.
- **W4b-5 focused regression** — same focused scope as W3 plus the new redo-service test. Phase 3 invariants untouched. helper G1-G11 stay green; G11 (S28) still passes; new S18 negative is green.
- **W4b-6 Codex narrow review on W4b commits.**
- **W4b-7 Backend restart → S18_Shot12 targeted redo max 2 attempts. Clean → step_run promotes via the new code. dirty (max 2 reached) → stop, separate setup-conflict wave.**
- **W4b-8 Then `scene_detail?mode=resume` must be a SKIP** (no LLM, no shot re-execution). If a redo still happens, the promote code did not land — re-investigate.
- **W4b-9 Then image phase resume.**

### W4b non-scope

- Helper logic widening to swallow real redraw intent.
- prompt scenario-specific patch for S18.
- manifest direct edit.
- manual SQL.
- `allow_partial_downstream` override.
- new verdict enum value.

## 8. Operational reminders (this wave)

- venv = `/Users/manta/Documents/Projects/TheRoad-I1/.venv/bin/python`. pytest CWD = `backend/`.
- backend restart required after any commit on this wave that touches `_owned_helpers.py` / `scene_context_loader.py` / `detail_steps.py`.
- All subagents = Opus 4.7 (model: "opus"). Sonnet/Haiku banned.
- All Codex communication via tmux-bridge raw workflow (`tmux_read → tmux_type body → tmux_read confirm → tmux_keys ["Enter"] → tmux_read response`). `tmux_message` forbidden.
- Codex messages start with `claude 에서 보냄` and end with `claude mcp 로 응답해줘`. Codex is consulted, not commanded.
- Do not ask the user. Scope/design/destructive judgment all go to Codex.
