# W19B-3 — background_render reference graph opt-in preflight

Status: **read-only design**. Production / prompt / test code patch 0. image
/ LLM / VLM API call 0. DB / ImageAsset / commit / push 0.

Companion to:
- W19A: floor_plan_prompt v5 → v6 opt-in selector (per-marker
  `base_layer_decision`) + W19A2 per-bg `use_numbered_elements` /
  `ignore_numbered_elements`.
- W19B-1: `floor_plan_overlay_payload` step + module (per-bg overlay payload).
- W19B-2: `background_prompt` v6 → v7 opt-in selector (layer-aware prompt).

W19B-3 is the next narrow wave: **wire the W18J overlap-driven reference
graph into `background_render` as an opt-in path**, default OFF, no image API
exercise in this wave.

## 1. Current production touchpoints (verified 2026-05-26)

### 1.1 `backend/app/core/steps/background_render_step.py`

- `_execute(...)` (line 126) — first gate is `if settings.background_mode not
  in {"on","floor_plan_anchored"}: return zero-filled result`. W19B-3 does
  **not** widen this gate.
- Inputs loaded (lines 142-148):
  - `background_master_plan` cp → `plans_map` (line 150) → bg DAG via
    `depends_on_bg[]`.
  - `background_prompt` cp → `prompts_map` (line 153) — per-bg `t2i_prompt`,
    `shot_guides`, `objects_owned_by_background`.
  - `floor_plan_render` cp → `fp_paths_str` (line 156) → fp_id → PNG path.
  - `floor_plan_prompt` cp → `fp_prompt_cp` (line 148) → `camera_recs_by_bg`
    (line 173).
- DAG construction (lines 199-237) uses `bg_filter_re` (D6 BG_ID_RE strict
  when D6 marker cp present). DAG parent = `bg.depends_on_bg[0]`.
- `_process(bid, snapshot)` (lines 281-360):
  - **fp_path resolution** (lines 294-297): `fp_first = (spec["depends_on_fp"]
    or [""])[0]`, then `fp_paths_str[fp_first]`. This is the only place the
    base FP PNG enters the reference list under the legacy path.
  - **prior_bg_paths resolution** (lines 299-303): walks `spec["depends_on_bg"]`
    and pulls each parent's rendered PNG from the level `snapshot`. This is
    the only place chain refs enter the legacy path.
  - **image API call site is delegated** to `render_one_background` (line 332)
    — `_process` itself does not call OpenAI.
- After all levels finish, `_register_chain_bg_image_assets` writes
  `ImageAsset` rows (line 411-451, `asset_type='chain_bg'`). **W19B-3 does
  not touch this DB write path; ImageAsset persistence stays as today.**

### 1.2 `backend/app/modules/pipeline/background_render.py`

- `render_one_background(*, openai_client, image_model, prompt, out_path,
  fp_path, prior_bg_paths, ...)` (lines 29-160). **This is the only function
  that calls the OpenAI image API.**
- Reference selection rule today (lines 57-69):
  ```
  ref_paths = []
  if fp_path is not None and fp_path.exists():
      ref_paths.append(fp_path)
  for p in prior_bg_paths:
      if p is not None and p.exists():
          ref_paths.append(p)
  ```
  i.e. **FP is always slot 0**, prior BG references are concatenated after
  (no cap on count).
- Dispatch (lines 83-114):
  - `len(ref_paths) >= 2` → `client.images.edit(image=[files])`.
  - `len(ref_paths) == 1` → `client.images.edit(image=f)`.
  - `len(ref_paths) == 0` → `client.images.generate(...)` (text-only).
- Moderation retry path (lines 138-155) uses `PromptSanitizer`; this is
  unchanged by W19B-3.
- Return shape: `{"status", "attempts", "strategies", "ref_used",
  "final_block_reason", "png_path"?}`. `ref_used ∈ {"text_only", "fp_only",
  "refs_<N>"}`.

### 1.3 `background_prompt_step.py` placeholder vs `background_render_step.py`

- `background_prompt_step.py:299-301` builds **placeholder strings**
  `f"<bg ref: {dep}>"` for every dependency, just to mention the chain in the
  LLM prompt text. These are **strings, not paths** — they never reach
  `render_one_background`.
- `background_render_step.py:299-303` resolves the **real PNG paths** from the
  `snapshot` dict (populated in level order, lines 365-366). The render step
  is the single point of truth for which on-disk PNGs become refs.
- The contract: `background_prompt_step` only narrates the chain to the
  authoring LLM; `background_render_step` independently re-resolves real
  PNG paths from the chain DAG. **W18J planner replaces the second of these
  two paths only.**

## 2. W18J contract → production opt-in (minimum design)

### 2.1 Selector

- `settings.background_render_reference_mode: Literal["legacy",
  "w18j_overlap"] = "legacy"` (W19A / W19B-2 selector pattern).
- `"legacy"` (default): `_process` keeps today's `fp_path` + `prior_bg_paths`
  flow byte-compatible. Planner is not invoked; no catalog is generated;
  result cp shape unchanged.
- `"w18j_overlap"` (opt-in): planner is invoked per bg and its
  `reference_paths` replaces `fp_path` + `prior_bg_paths`. Catalog is
  generated as an ephemeral artifact (see §2.3). Hard flips rejected: must be
  explicit opt-in, same as `floor_plan_prompt_version` and
  `background_prompt_version`.
- `_config_hash` payload composition rule (locked, addresses Codex
  BLOCKING 2 on byte-identical default hash):
  - When `settings.background_render_reference_mode == "legacy"` (default),
    the hash payload remains **byte-identical to today's payload**
    `{background_mode, schema_version, prompt_version}`. The new selector
    key is **not** added. Existing v6/v7 checkpoints stay valid; no
    regeneration is triggered just by introducing the field.
  - When `settings.background_render_reference_mode == "w18j_overlap"`,
    the hash payload additionally stamps
    `"background_render_reference_mode": "w18j_overlap"` (the resolved
    selector value). This is the only path that invalidates existing
    legacy checkpoints, and only for projects that explicitly opt in.
  - No other field is added to the legacy payload. Catalog presence inside
    `data.bg_reference_catalog` (§2.3) does not feed the hash — it is run
    output, not a configuration input.

### 2.2 Planner module

- New file `backend/app/modules/pipeline/background_image_planner.py`,
  pure-function, deterministic. No LLM / image / VLM imports.
- Public entry:
  ```
  build_reference_decision(
      *,
      bg_id: str,
      fp_id: str,
      base_fp_png: Optional[Path],
      overlay_payload: dict,           # W19B-1 per-bg payload
      catalog: list[CatalogEntry],     # accepted prior BGs of the same fp_id
      max_refs_per_bg: int = 2,
  ) -> ReferenceDecision
  ```
- 3 mode dispatch (exact-ID, no regex / substring):
  - Compute `shared_units`, `shared_base_markers`, `score`,
    `is_strong_overlap` against every catalog entry whose `fp_id == fp_id`.
  - If any strong overlap → `mode = "reference_derived"`, pick top-2 by
    `(score desc, ref_bg_id asc)`. `fp_included = False`.
  - Else if `dominant_target_unit_marker_number` from overlay_payload is not
    in any catalog entry's `unit_marker_set` AND at least one weak-overlap
    catalog entry exists → `mode = "style_reference_new_space"`, pick top-1
    (W19B-3 default; preflight §6.2 lock in §5.2 below). `fp_included = False`.
  - Else → `mode = "fp_seeded_anchor"`, `reference_paths = [base_fp_png]`,
    `fp_included = True`.
- Module-level frozenset of allowed modes — exact membership enforced.
- Mode-keyed guidance text registry (3 entries) lives in this module so the
  prompt prose surface remains static and BG-id keyed prose stays at zero.

### 2.3 Catalog (ephemeral)

- **Location: `background_render` checkpoint, under
  `data.bg_reference_catalog`.** No separate step, no DB row, no
  `ImageAsset` row beyond today's chain_bg path.
- Entry shape (W19B brief §3.3 + W19B-3 lock — marker-number identity, no
  unit_id strings, per W19B-1 lock):
  ```
  {
    "bg_id": str,
    "png_relative_path": str,
    "fp_id": str,
    "unit_marker_set": list[int],            # base_structural_unit markers
    "base_marker_set": list[int],            # all base_* markers
    "ingestion_order": int,
    "source_kind": "derived",                # W19B never writes catalog_seed
    "immutable": True,
    "regen_allowed": False
  }
  ```
- Append-only within a single run. A bg that returns `status != "ok"` never
  enters the catalog. Catalog is rebuilt from this cp on resume — no DB
  persistence in this wave.

### 2.4 Per-bg planner record (`overlap_reference_decision`) + render audit

- Persisted under `background_render` cp `data.groups[bg_id].reference_decision`:
  ```
  {
    "mode": "fp_seeded_anchor" | "reference_derived" | "style_reference_new_space",
    "fp_included": bool,
    "reference_paths": list[str],            # max 2, resolved from catalog or base FP
    "selected_refs": [
      {"ref_bg_id": str, "score": float, "shared_units": list[int],
       "shared_base_markers": list[int], "is_strong_overlap": bool}
    ],
    "candidate_scores": [...],               # all catalog entries scored, for audit
    "source_bg_ids": list[str],              # selected_refs[].ref_bg_id only
    "fp_id": str,
    "decision_reason": str,                  # mode-keyed prose, no bg_id leak
    "reference_guidance_prefix": str,        # mode-keyed prose prepended to images.edit prompt (may be "")
    "max_refs_per_bg": 2,
    "diagnostics": list[str]
  }
  ```
- `mode == "fp_seeded_anchor"` → `selected_refs == []`, `source_bg_ids == []`,
  `reference_paths == [base_fp_png_path]`.
- Render-time audit fields (locked, addresses Codex IMPORTANT 2):
  - When the opt-in path prepends a mode-keyed guidance prose to the actual
    `images.edit` prompt, the effective prompt and the
    `background_prompt` checkpoint's `t2i_prompt` diverge. To keep that
    divergence inspectable without re-emitting it through the v7 prompt
    pack (locked in §5.3), the render step persists two additional audit
    fields on `data.groups[bg_id]`:
    - `effective_render_prompt`: the actual text passed to
      `images.edit` for this bg, i.e. `reference_guidance_prefix +
      t2i_prompt` (concatenation order locked).
    - `reference_guidance_prefix`: identical copy of the planner record's
      same-name field, surfaced one level up for ease of audit.
  - Both fields are audit-only; downstream consumers (scene_detail,
    composite image gen, etc.) continue to read `t2i_prompt` from
    `background_prompt` cp. The v7 prompt pack stays untouched.
  - In `legacy` selector mode neither field is written (cp shape
    byte-compatible).

### 2.5 `background_render_step._process` integration point

- Single insertion site (line 293, before `fp_path` / `prior_bg_paths`
  derivation):
  - Read `settings.background_render_reference_mode`.
  - Branch:
    - `"legacy"` → existing lines 294-303 run verbatim → existing
      `render_one_background(..., fp_path=..., prior_bg_paths=...)` call.
    - `"w18j_overlap"` → call `build_reference_decision(...)`. Use the
      returned `reference_paths` exclusively. `render_one_background(...)`
      receives **either** `fp_path = base_fp_png, prior_bg_paths = []`
      (mode `fp_seeded_anchor`) **or** `fp_path = None,
      prior_bg_paths = reference_paths` (modes `reference_derived` /
      `style_reference_new_space`). FP and catalog refs are never combined.
- Catalog mutation: after a successful render of `bid`, append a
  `CatalogEntry` for it. Persist in `data.bg_reference_catalog` (§2.3).

### 2.6 Opt-in sequential render queue (BLOCKING 1 lock)

Today (`background_render_step.py:362-370`) the render loop walks DAG
levels with `compute_dag_levels` and dispatches each level's bgs through a
`ThreadPoolExecutor`. Each thread sees only the `snapshot` of paths taken
at the start of that level. This is correct for the legacy
`depends_on_bg` chain (which only references previously-completed levels)
but it is incompatible with the W18J catalog seed-and-grow contract:
catalog mutations made by one bg in level _N_ must be visible to the
**next** bg in the same level _N_ when computing its reference decision.

The opt-in path therefore replaces the per-level ThreadPool with a
deterministic per-fp sequential render queue. The legacy path is **not**
changed — same DAG levels, same ThreadPool, same `snapshot` semantics —
so existing checkpoints / regression remain byte-compatible.

Locked queue contract (W19B-3):

- Branch site: inside `background_render_step._execute`, **before** the
  `compute_dag_levels` / `ThreadPoolExecutor` loop, branch on
  `settings.background_render_reference_mode`.
  - `"legacy"` → existing loop verbatim (lines 362-407).
  - `"w18j_overlap"` → run a new helper
    `run_sequential_overlap_render_queue(...)` that owns the entire render
    pass for the opt-in path.
- Per-fp partitioning: bgs are grouped by `fp_id` (resolved via
  `bg.depends_on_fp[0]`). Each fp's bg set is rendered as one independent
  queue. Different fps may run in parallel only at the per-fp granularity
  (W19B-3 keeps it simple — single-process serial across fps too; the
  fp-parallel optimization is left to a follow-up wave).
- Per-fp queue algorithm:
  1. `pending` ← every bg of this fp that is `renderable` (its prompt cp
     entry has `status == "ok"`).
  2. While `pending` is non-empty:
     - For each `bg ∈ pending`, compute `build_reference_decision(...)`
       against the **current** catalog (which contains all
       successfully-rendered bgs of this fp so far). This is a pure
       function — no side effect.
     - Pick the next bg from `pending` using the locked priority order
       (see "Next-bg selection" below).
     - Call `render_one_background(...)` with the decision's
       `reference_paths`.
     - On success: append a `CatalogEntry` to the per-fp catalog (and to
       cp `data.bg_reference_catalog`); remove the bg from `pending`.
     - On failure: do **not** append a catalog entry; remove the bg from
       `pending` and record the failure under
       `data.groups[bg_id]`. The queue continues with the remaining
       pending bgs (failures do not block subsequent renders).
- Next-bg selection (locked, exact-ID only — no scenario text / substring
  / lexical inference):
  - **Anchor phase** (catalog empty for this fp): pick the seed bg by:
    1. `clean_background_expected == True` first (a clean BG is the
       safest anchor — its prompt body is the most generic);
    2. then by structural-coverage score
       `len(target_unit_marker_numbers) + 0.5 *
       len(base_markers_to_reference)` (descending);
    3. stable tiebreak: `(bg_id ascending, master_plan_order ascending)`.
       Both keys are exact identifiers from the overlay payload and the
       master_plan checkpoint; no text inspection.
  - **Follow-on phase** (catalog non-empty for this fp): pick the next bg
    by the planner's mode preference applied to the **current** catalog:
    1. any `pending` bg whose decision under the current catalog comes
       back as `reference_derived` (strong overlap) — pick the one with
       the highest planner `score`; same stable tiebreak;
    2. else any `pending` bg whose decision comes back as
       `style_reference_new_space` (weak overlap fallback) — pick the
       single highest-score candidate;
    3. else any `pending` bg that the planner would route to
       `fp_seeded_anchor` (means it has no overlap at all with the
       current catalog — treat as a new sub-anchor, still using the base
       FP png only); same stable tiebreak.
  - Re-evaluation is mandatory: the decision recorded on
    `data.groups[bg_id].reference_decision` is the one taken **at the
    moment** of that bg's render, against the catalog snapshot at that
    moment. Subsequent catalog growth does not retroactively change the
    record.
- Planner helper signature (new, in
  `backend/app/modules/pipeline/background_image_planner.py`):
  ```
  def build_overlap_render_queue(
      *,
      renderable_bg_ids: list[str],
      bg_specs: dict[str, dict],
      overlays_by_bg: dict[str, dict],
      fp_paths_str: dict[str, str],
  ) -> dict[str, list[str]]:
      """Returns {fp_id: [bg_id, ...]} initial pending ordering by the
      stable identifier tiebreak only. Per-step next-bg selection happens
      inside the step's queue loop, using build_reference_decision against
      the live catalog."""
  ```
  Plus the existing pure helper:
  ```
  def build_reference_decision(*, bg_id, fp_id, base_fp_png, overlay_payload,
                               catalog, max_refs_per_bg=2) -> ReferenceDecision:
      ...
  ```
- ThreadPool reuse: the legacy `ThreadPoolExecutor` in
  `background_render_step.py:368-370` is **not** invoked from the opt-in
  path. `run_sequential_overlap_render_queue(...)` runs each bg on the
  calling thread, serially per fp.

## 3. Fail-closed conditions

- Selector `"legacy"` (default) → no fail-closed branch added. Result cp /
  PNG content / `_config_hash` byte-compatible with today.
- Selector `"w18j_overlap"` + missing `floor_plan_overlay_payload` cp or
  missing overlay entry for a bg → **bg-level fail-closed**, status
  `"failed"`, error string `"w18j_overlap: missing overlay payload for
  bg=<bid>"`. `render_one_background` is **not** called for that bg; no
  image API spend.
- Selector `"w18j_overlap"` + planner cannot resolve `reference_paths` to
  existing files → fail-closed, status `"failed"`, error string
  `"w18j_overlap: reference path unresolved"`. No image call.
- Selector `"w18j_overlap"` + sequential queue cannot pick an anchor for a
  non-empty `pending` (e.g. no bg has a valid overlay payload) →
  fail-closed at the fp level: every remaining `pending` bg is marked
  `status="failed"` with `"w18j_overlap: anchor selection failed for fp
  <fp_id>"`. No image API spend for that fp.
- Hard invariants in the planner (raise `ValueError` if violated):
  - `max_refs_per_bg = 2` (constant). `len(reference_paths) <= 2`.
  - FP and catalog refs mutually exclusive: if `fp_included is True`, then
    `selected_refs == []` and `source_bg_ids == []`.
  - Mode value in `{"fp_seeded_anchor","reference_derived",
    "style_reference_new_space"}` exact set.
  - All catalog candidates filtered by `fp_id == bg.fp_id` only (no
    cross-fp leakage).
  - BG-id keyed prose / dict 0 (static-code grep in tests, see §4).
- Catalog append-only: a bg whose render returns `status != "ok"` is
  **never** appended to the catalog; a subsequent re-run of the same bg
  produces a fresh `reference_decision` against the catalog snapshot at
  that moment (no retroactive mutation of existing entries).
- `background_render_step.py` wraps the planner call in `try/except` and
  converts `ReferenceDecisionError` (new exception in planner module) to a
  bg-level failed entry. The queue loop continues for the remaining
  pending bgs of the same fp; level/ThreadPool parallel is only used on
  the legacy branch.

## 4. Test plan (minimal — 8 tests, production-side only)

Layout: new `tests/pipeline/test_background_image_planner.py` (5 tests) +
new `tests/core/test_background_render_step_w19b3.py` (3 tests). No new
prompt pack; no LLM/image/VLM imports.

1. **Default selector keeps existing render path** — patch settings
   `background_render_reference_mode="legacy"`; assert `_process` resolves
   `fp_path` from `depends_on_fp[0]` and `prior_bg_paths` from
   `depends_on_bg[]`, planner module is not invoked, and
   `data.bg_reference_catalog` is absent from the result.
   **Also assert the legacy ThreadPool / level path is taken** (e.g. by
   patching `run_sequential_overlap_render_queue` and asserting it was
   not called, or by inspecting `_config_hash` payload to confirm the new
   selector key is not stamped — BLOCKING 2 byte-compat).
2. **Opt-in missing overlay payload fails before image call** — patch
   selector `"w18j_overlap"`, omit the `floor_plan_overlay_payload` cp;
   mock `render_one_background` and assert it is **not called**, result
   `bg["status"] == "failed"` with overlay error string. Also assert
   `_config_hash` payload now contains
   `"background_render_reference_mode": "w18j_overlap"` (the only path
   that mutates the hash).
3. **Opt-in queue uses sequential path, not ThreadPool** — patch selector
   `"w18j_overlap"`, supply a minimal valid overlay payload and two
   pending bgs in the same DAG level. Assert that
   `run_sequential_overlap_render_queue` is invoked exactly once and that
   the underlying `ThreadPoolExecutor` for the legacy branch is **not**
   instantiated for that fp.
4. **Opt-in second decision sees the first bg's catalog entry** — using a
   mock `render_one_background` that returns success for the first bg
   only, supply two bgs A and B in the same fp with overlapping markers.
   Assert the second bg's recorded `reference_decision.selected_refs`
   contains `A` as a ref (proving catalog grew between the two queue
   iterations). The legacy ThreadPool path could not produce this result.
5. **Opt-in failed render does not pollute catalog** — sequence: bg A
   render returns `status="failed"`, bg B follows. Assert bg A is not in
   `data.bg_reference_catalog`, bg B's planner decision is computed
   against an empty (or pre-failure) catalog, and the queue continues to
   process B.
6. **`fp_seeded_anchor` selects base FP only** — pure planner unit:
   empty catalog → `mode == "fp_seeded_anchor"`,
   `reference_paths == [base_fp_png]`, `fp_included is True`,
   `selected_refs == []`.
7. **`reference_derived` selects prior BG refs only, no FP** — pure
   planner unit: catalog has a strong-overlap entry → `mode ==
   "reference_derived"`, `fp_included is False`, `len(reference_paths) <=
   2`, base FP path not in `reference_paths`.
8. **`style_reference_new_space` capped at one ref** — pure planner unit:
   no strong overlap but at least one weak-overlap catalog entry whose
   `unit_marker_set` does not contain the dominant target marker →
   `mode == "style_reference_new_space"`, `len(reference_paths) == 1`,
   `fp_included is False`.

Tests 1-5 live in `tests/core/test_background_render_step_w19b3.py`
(integration mocks around `_execute` / `render_one_background`). Tests
6-8 live in `tests/pipeline/test_background_image_planner.py` (pure
function unit tests).

Run only: `tests/pipeline/test_background_image_planner.py`,
`tests/core/test_background_render_step_w19b3.py`,
`tests/pipeline/test_floor_plan_overlay_payload.py`,
`tests/core/test_floor_plan_overlay_payload_step.py`,
`tests/pipeline/test_background_prompt_v7.py`,
`tests/core/test_background_prompt_step_v7.py`. Broad `tests/scripts`
regression stays out of scope.

## 5. Locked decisions (Codex approved 2026-05-26)

### 5.1 Catalog location — LOCKED: render cp internal

- `data.bg_reference_catalog` lives inside the existing `background_render`
  checkpoint. No separate step is added; no DB row; no ImageAsset entry
  beyond today's `asset_type='chain_bg'` path. Per-run ephemeral, rebuilt
  from the same cp on resume.

### 5.2 Selector — LOCKED: `background_render_reference_mode`

- `settings.background_render_reference_mode: Literal["legacy",
  "w18j_overlap"] = "legacy"`.
- `legacy` (default) — existing render path, byte-compatible hash and cp
  shape (see §2.1 BLOCKING 2 lock).
- `w18j_overlap` (opt-in) — sequential queue (§2.6), catalog seed-and-grow
  (§2.3), planner record (§2.4).

### 5.3 Mode-keyed guidance — LOCKED: planner record only

- Mode-keyed guidance text stays inside the planner module as a static
  registry (3 entries: `fp_seeded_anchor`, `reference_derived`,
  `style_reference_new_space`). The chosen entry is recorded on the
  planner record's `decision_reason` and `reference_guidance_prefix`
  fields, and is prepended to the `images.edit` prompt at render time
  only (see §2.4 audit fields).
- `background_prompt` v7 prompt pack is **not** modified in this wave.
  Re-evaluation deferred to W19C after first cross-fp validation.

### 5.4 Open decisions remaining

- **None.** All three preflight decisions are locked above. W19B-3 code
  wave is unblocked.

## 6. Guard summary

- Production / app / prompt / test code: **0 byte change** in this preflight
  wave.
- image / LLM / VLM API call: **0**.
- DB write / ImageAsset write: **0**.
- commit / push: **0**.
- broad `tests/scripts` regression: not run.
- scenario-specific tokens / BG-id keyed prose / scenario-specific named
  props: 0 in this document.
- `_config_hash` of `background_render_step` under default selector
  `"legacy"` is byte-compatible with the current production hash (planner
  branch + catalog stamping only activate under `"w18j_overlap"`).

All three preflight decisions (§5.1 / §5.2 / §5.3) are locked. **No open
decisions remain.** The W19B-3 code wave touches exactly:

- `backend/app/core/config.py` — add one selector field
  `background_render_reference_mode: Literal["legacy","w18j_overlap"] =
  "legacy"`. No other setting touched.
- New module
  `backend/app/modules/pipeline/background_image_planner.py` — public:
  `build_reference_decision(...)` (pure), `build_overlap_render_queue(...)`
  (pure), `_REFERENCE_MODE_GUIDANCE` (static dict with exactly 3
  mode-keyed entries, no bg_id keys), `ReferenceDecisionError`. No LLM /
  image / VLM imports.
- `backend/app/core/steps/background_render_step.py` — additions only:
  (a) read selector at top of `_execute`; (b) `_config_hash` payload
  composition per §2.1 BLOCKING 2 rule (legacy path leaves payload
  byte-identical, opt-in path stamps the resolved selector value); (c)
  branch around the existing level/ThreadPool loop — when selector is
  `w18j_overlap`, call the new `run_sequential_overlap_render_queue(...)`
  helper that owns the entire opt-in render pass for that step
  invocation; legacy branch remains verbatim; (d) catalog append after
  each successful render in the opt-in branch, persisted via cp
  serialization as `data.bg_reference_catalog`; (e) per-bg planner
  record + render audit fields (§2.4) under `data.groups[bg_id]`.
- Two minimal test files per §4:
  `tests/pipeline/test_background_image_planner.py` (pure planner unit
  tests, 3 tests) +
  `tests/core/test_background_render_step_w19b3.py` (step-level
  integration mocks, 5 tests covering legacy bypass, opt-in hash, queue
  vs ThreadPool, catalog growth between same-level bgs, failed render
  not entering catalog).

Nothing else in `backend/app`, `backend/alembic`, or `prompts/_base` is
touched by the W19B-3 code wave. `background_prompt` v7 pack, W19B-1
overlay payload step/module, W19A floor_plan_prompt v6 pack — all
unchanged.
