# W19A — `floor_plan_prompt` v6 implementation preflight

Status: **read-only audit / design only**, no code patch, no prompt
rewrite. Companion to
[`2026-05-26-w19-floor-plan-background-migration.md`](2026-05-26-w19-floor-plan-background-migration.md).

Scope of this preflight: identify every file the W19A wave will touch
(prompt pack, schema, step wrapper, downstream consumers), pin the
verified root-cause lines in the current v5 path, and lay out the
exact patch intent the W19A coding wave will execute. No production
code, prompt, schema, DB, ImageAsset, commit, push, or new LLM /
image / VLM call is produced by this document.

Reviewer: Codex (must approve before W19A code wave begins). Open
decisions are surfaced in §6; the W19A code wave is blocked on those.

## 1. Current file map

All paths verified to exist via direct read on 2026-05-26.

### 1.1 Prompt pack — `prompts/_base/floor_plan_prompt/`

| version dir                  | role                                                    |
| ---------------------------- | ------------------------------------------------------- |
| `1.202604292033/`            | legacy (early Phase 7)                                  |
| `2.202604300800/`            | legacy                                                  |
| `3.202604301041/`            | legacy                                                  |
| `4.202605091200/`            | legacy                                                  |
| `5.202605201406/`            | **current production** — system / user_template / schema |

The current pack carries three files per version directory:
`system.md`, `user_template.md`, `schema.json`. The prompt loader
(see §1.4) resolves stems independently per module + version, with
numeric-desc sort over the leading integer.

### 1.2 Production step + module

| file                                                                                                | role                                                                  |
| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `backend/app/modules/pipeline/floor_plan_prompt.py`                                                 | LLM call orchestration, schema enum injection, output cross-validation |
| `backend/app/core/steps/floor_plan_prompt_step.py`                                                  | StepRunner wrapper; computes `config_hash`, drives ThreadPoolExecutor  |
| `backend/app/modules/prompt_loader.py`                                                              | DB-first + file-fallback prompt + schema loader; numeric-desc version  |
| `backend/app/modules/pipeline/floor_plan_render.py` *(referenced)*                                  | Image rendering helper (called by `FloorPlanRenderStep`)               |
| `backend/app/core/steps/floor_plan_render_step.py`                                                  | Downstream FP image rendering step (not touched by W19A)              |
| `backend/app/modules/pipeline/background_prompt.py`                                                 | Downstream consumer of `numbered_elements` (relevant for §4)           |
| `backend/app/core/steps/background_prompt_step.py`                                                  | Downstream BG step wrapper (relevant for §4)                           |

### 1.3 Version hook (the only "version selector" in production)

The production version selector is **not** a registry / loader
mechanism. The `prompt_loader` always reads the highest-sorted
version directory under `prompts/_base/floor_plan_prompt/`
(`_version_sort_key`, lines 50–67 in `prompt_loader.py`). The
production step pins the version it claims to be running by a
**source-level constant**:

- `backend/app/core/steps/floor_plan_prompt_step.py:31`
  → `PROMPT_VERSION = "5.202605201406"`

This constant feeds `_config_hash` (line 41–48), so the
config-hash-stamped checkpoint key invalidates whenever
`PROMPT_VERSION` changes. There is no `prompt_version_registry` or
flag-based selector — version selection is implicit (latest pack on
disk) and the constant only serves as the checkpoint invalidation
key + an audit marker.

Implication for W19A: the migration cannot land "behind a flag" in
the source code today. **Codex decision locked (§6.1):** W19A
introduces an explicit version selector in the step wrapper
(option (a)) — default is `"5"`, v6 is opt-in via the setting. Hard
flip-once (option (b)) is **rejected** (no in-band rollback). The
selected version must be carried into both the prompt/schema load
path and `_config_hash`. See §1.4 for the companion loader risk,
§3.6 for the proposed loader surface, and §4.7 for the latest-pack
auto-resolve risk that this selector mitigates.

### 1.4 Loader semantics relevant to W19A

- `load_prompt(module, name, db=None, **kwargs)` — DB row wins if
  active, else falls back to file (`prompt_loader.py:108–137`).
- `load_schema(module, name, db=None)` — same precedence
  (`prompt_loader.py:140–164`).
- `_resolve_stem_in_pack` — numeric-desc iteration over version dirs;
  the first dir that contains the stem wins (`prompt_loader.py:219–
  235`). This is why a new `6.YYYYMMDDHHmm/` directory becomes
  effective on disk **even before** `PROMPT_VERSION` is bumped in
  Python.
- `PROMPT_VERSION_PACK_STRICT` env var optionally fails when stems
  fall outside the latest pack (`prompt_loader.py:183–198`). The
  W19A wave should drop all three stems into the new v6 pack
  together to avoid stem-drift warnings.

### 1.5 Downstream consumers of the FP prompt output

The `floor_plan_prompt` step persists into the checkpoint the
following fields per `fp_id` (`floor_plan_prompt_step.py:225–237`):

```
{
  "status": "ok",
  "t2i_prompt": <string>,
  "key_elements": [<string>],
  "numbered_elements": [{number, label, category, position_hint}],
  "camera_recommendations": [{bg_id, sub_location, camera_position,
                              camera_height, lens_hint, framing_notes}],
  "applied_shots": [...],
  "group_id": ...,
  "depends_on_fp": [...]
}
```

Consumers and how each field is used today:

| consumer                                                    | reads                                                | how                                                                                                                     |
| ----------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `FloorPlanRenderStep` (`floor_plan_render_step.py:384–423`) | `t2i_prompt` only (for `images.edit` payload)        | `render_one_floor_plan(prompt=prompt_entry["t2i_prompt"], …)` — does **not** consume `numbered_elements`                |
| `background_prompt` (`background_prompt.py:42–60, 127`)     | `numbered_elements[]`                                | serialized verbatim into BG user prompt as `"<number>. <label> [<category>] — <position_hint>"` per entry               |
| `background_prompt` (`background_prompt.py:63–80, 128`)     | `camera_recommendations[]` filtered to current bg_id | serialized into BG user prompt as `camera_position / camera_height / lens_hint / framing_notes` block                   |

Key downstream property for W19A:

- `numbered_elements` is serialized **as a flat list** with no layer
  partition awareness. Adding a new field to each entry (e.g.
  `base_layer_decision`) is **forward-compatible** as long as the
  W19A wave does not change the `_format_numbered_elements_block`
  format string (or its content semantics): the new field is simply
  not read by the v5/v6-era BG prompt. The v7 BG prompt wave (W19B)
  is the consumer that must start filtering on the new field.

## 2. Verified root cause lines

The W19 brief §1.1 claims v5 instructs the FP renderer to embed
transient cues in the FP image. The verifications below pin that
claim to specific lines.

### 2.1 v5 system prompt explicitly admits transient cues

`prompts/_base/floor_plan_prompt/5.202605201406/system.md` Rule 4
(line 12):

> "every persistent prop, doorway, window, **plot-critical visual
> device**, and furniture item visible in downstream backgrounds
> MUST appear in the diagram as a small circle with a black number"

The string `plot-critical visual device` is the root licence in the
v5 system prompt for emitting transient state cues (wall marks,
floor trails, displaced objects) onto the FP image as numbered
markers. W17 wave evidence: when the W15e candidate carried 23
markers, every transient marker was honored as `must_show` and the
FP geometry bent to accommodate them.

### 2.2 v5 schema bakes `plot_device` into the `numbered_elements` category

`prompts/_base/floor_plan_prompt/5.202605201406/schema.json` line
14:

```
"category": {"type": "string",
             "enum": ["furniture", "opening", "prop", "plot_device", "area"]}
```

The category enum **must** include `plot_device` for any v5 LLM
output to validate. There is no way to express "this marker is
transient" outside `plot_device`, and there is no way to mark a
marker as base / overlay. The schema itself prevents the layer
separation the W19 brief calls for.

### 2.3 No partitioning data on the user side

`backend/app/modules/pipeline/floor_plan_prompt.py` `build_fp_user_prompt`
(lines 46–112) inserts the verbatim scene_segments and
applied_backgrounds detail block. There is no marker partition input
because the contract does not exist yet. The user prompt's only
partition-shaped content today is the `Valid bg_ids:` block.

### 2.4 Cross-field validator does not constrain marker categories

`backend/app/modules/pipeline/floor_plan_prompt.py`
`_validate_fp_prompt_extras` (lines 115–171) enforces:

- `numbered_elements.number` uniqueness and `>= 1`,
- `camera_recommendations.bg_id` exact-set against `expected_bg_ids`
  (D6 v4 contract).

It does **not** assert anything about `category`. The schema enum is
the only constraint; if `category` were to gain new values, the
schema must change first.

### 2.5 Downstream serialization is layer-blind

`backend/app/modules/pipeline/background_prompt.py:42–60`
serializes every numbered_elements entry into one line regardless of
category. There is no consumer-side filter on `category`. A v5 FP
that puts a transient cue into `numbered_elements` propagates that
cue verbatim into the BG prompt body.

## 3. Proposed W19A patch set

Each item below is **intent only**, not a code change. The W19A
coding wave will produce the actual diffs after this preflight is
Codex-approved.

### 3.1 New prompt pack — `prompts/_base/floor_plan_prompt/6.YYYYMMDDHHmm/`

All three stems land together (avoid pack drift; see §1.4).

#### 3.1.1 `system.md` (v6) — intent

- Replace Rule 4's `plot-critical visual device` license with a
  base-layer-only contract: the FP image must depict structural
  units, openings, persistent fixed fixtures, and persistent anchor
  furniture **only**.
- Add a new explicit rule: transient / state-overlay markers (event
  cues, displaced objects, mutable wall / floor marks) **must not**
  be drawn on the FP image; they are carried separately by the
  background overlay payload.
- Add the W19 brief §2.2 enum (`base_layer_decision`, 6 values) as
  a per-marker field. Every numbered_elements entry must carry a
  `base_layer_decision`; only `base_*` decisions are drawn into the
  FP image.
- Keep all existing v5 rules unrelated to marker partition
  (flat-schematic style, color fills, 1024×1024, numbered-marker
  contract, English-only, generic descriptors, cultural-cue
  derivation, camera_recommendations bg_id contract).

#### 3.1.2 `schema.json` (v6) — intent

Schema delta against v5:

- `numbered_elements.items` adds a required `base_layer_decision`
  field with `enum = ["base_structural_unit", "base_opening",
  "base_persistent_fixture", "base_persistent_furniture",
  "state_overlay_plot_cue", "state_overlay_transient_object"]`.
- `numbered_elements.items.category` enum has `plot_device` removed
  to remove the source-level licence that allows transient cues to
  appear as base markers. The remaining categories
  (`furniture`, `opening`, `prop`, `area`) stay as a generic
  shape-kind hint; the **layer** decision moves entirely to
  `base_layer_decision`.
- `additionalProperties` stays `false` on `numbered_elements.items`
  to prevent silent drift.
- `t2i_prompt` minLength stays as is. `camera_recommendations`
  block is unchanged.
- `required` on `numbered_elements.items` extended to include
  `base_layer_decision`.

Open decision: see §6.3 about whether `plot_device` removal vs.
deprecation is the right path.

#### 3.1.3 `user_template.md` (v6) — intent

No structural change to placeholders. Carries the same blocks
(`{fp_id}`, `{sub_location}`, `{scope}`, `{valid_bg_ids_block}`,
`{backgrounds_block}`, `{shots_block}`, `{scene_segments_block}`,
`{visual_world_rules}`). The v6 system prompt is responsible for
all new layer contract semantics; the user prompt remains a
content-only carrier so existing `build_fp_user_prompt` does not
need to change shape.

### 3.2 Step wrapper — `floor_plan_prompt_step.py`

- **Do not** unconditionally bump the constant `PROMPT_VERSION` from
  `"5.202605201406"` to `"6.YYYYMMDDHHmm"`. Per locked decision
  §6.1, default stays v5 and v6 is opt-in. Replace the static
  constant pattern with a runtime selector:
  - Add a new setting (e.g. `settings.floor_plan_prompt_version`)
    with default `"5"`. Acceptable values: `"5"` and `"6"`.
  - The step resolves the active version string at execution time
    (mapping `"5"` → `"5.202605201406"`, `"6"` →
    `"6.YYYYMMDDHHmm"`); both pack directories remain on disk.
  - The active version string is included in `_config_hash` so the
    checkpoint key invalidates only for projects whose selector flips
    to v6. v5 callers see identical hashes as today.
- `SCHEMA_VERSION` stays at the current value (`2`) by default. Per
  locked decision §6.2, this constant is bumped **only** if the W19A
  coding wave introduces a new **top-level** result-shape change
  (e.g. adds a `prompt_version` field at the result root, or moves a
  top-level field). Adding nested fields under `numbered_elements`
  is **not** a top-level shape change; the selector value +
  `_config_hash` carries the invalidation deterministically. Do not
  blindly bump `SCHEMA_VERSION` just for the v6 pack.
- No new field is written to the persisted entry beyond what the
  LLM returns. The new `base_layer_decision` rides inside each
  `numbered_elements` dict, so persistence is automatic via the
  existing `out.get("numbered_elements") or []` line
  (`floor_plan_prompt_step.py:232`).
- `_execute` is extended only to (a) read the selector setting and
  (b) pass the resolved active version into the prompt/schema load
  path (§3.6) and into the validator (§3.3 — validator must know the
  active version to apply v6-only checks). The user prompt build
  pipeline does not need a new input; partition responsibility lives
  in the LLM under v6's system prompt rules.

### 3.3 Module — `floor_plan_prompt.py`

- `_validate_fp_prompt_extras` gains a **version-aware** invariant.
  The selector value (§3.2) is plumbed into the validator. When the
  selected version is `"6"`, every `numbered_elements` entry must
  carry `base_layer_decision` ∈ the v6 schema enum (exact-set on the
  enum, `set` membership only — no regex, no substring, no lexical
  inference). When the selected version is `"5"` (default), the v5
  validator behavior is preserved **verbatim** — no
  `base_layer_decision` check, no `category` retraction. This honors
  Codex's BLOCKING 1: a v6-only requirement applied to v5 callers
  would break the default path.
- The W19A contract is **two-part** (Codex BLOCKING 2 clarification):
  1. The v6 `system.md` requires the LLM to author `t2i_prompt` as a
     base-only render of the FP image (structural units, openings,
     persistent fixed fixtures, persistent anchor furniture). The
     image's base-only behavior is enforced by the LLM via the v6
     system prompt, not by any post-hoc filter.
  2. The v6 schema requires every `numbered_elements` entry to carry
     `base_layer_decision`, so downstream consumers can partition
     the marker list by layer. W19A's validator only verifies the
     partition tag is present and ∈ enum; it does **not** filter
     overlay entries out of the LLM result.
- "Image-side filter" is a **non-concept** in this migration. The FP
  image renderer (`floor_plan_render_step.py`) consumes only
  `t2i_prompt` and never iterates `numbered_elements`; under v6 the
  `t2i_prompt` body is authored base-only by the LLM, so there is no
  overlay content reaching the image at all. The W19B wave's filter
  is on the **BG prompt body**, not on the FP image. W19A's job is
  to (a) ship the v6 system-prompt base-only contract for
  `t2i_prompt` and (b) emit the `base_layer_decision` partition tag
  per marker for W19B to consume.
- `validate_fp_prompt_output` (lines 202–220) stays unchanged
  (fp_id match + non-ASCII guard + minLength).
- `_inject_bg_id_enum` (lines 174–199) stays unchanged.

### 3.4 No production-side image renderer change in W19A

`floor_plan_render_step.py` reads only `t2i_prompt` from the prompt
checkpoint (`floor_plan_render_step.py:412–414`). Under v6 the
`t2i_prompt` body no longer encodes transient markers — the v6
system prompt authors it base-only (§3.3 part 1). Therefore W19A
does **not** need to touch the renderer, the render step, or the
ImageAsset registration path. There is no renderer-side filter to
add and none is required; the base-only contract for the FP image
is enforced inside the v6 system prompt itself.

### 3.5 No background_prompt change in W19A

`background_prompt.py:42–60` serializes every numbered_elements
entry. v6 will add a `base_layer_decision` field to each entry, but
`_format_numbered_elements_block` reads only `number`, `label`,
`category`, `position_hint`. The extra field is silently ignored
(forward-compatible). The W19B wave (background_prompt v7) is where
the overlay entries of `numbered_elements` are filtered out of the
**BG prompt body** (the FP image is a separate surface and already
sees no overlay markers under v6 — see §3.3, §3.4).

Concretely: under v6 FP + v5/v6 BG, the BG prompt body may still
mention overlay markers because the consumer does not yet filter
them. This is explicitly **expected behavior during W19A → W19B
transition** and is one of the rollout-risk items (§4.1).

### 3.6 Prompt/schema loader — versioned-load surface

`prompt_loader.py` today resolves the highest-sorted version
directory under `prompts/_base/floor_plan_prompt/` and has no
per-call version pinning (§1.4). Once a new `6.YYYYMMDDHHmm/` pack
lands, the latest-pack resolver will **always** prefer v6, making
"default v5" (§6.1 lock) physically impossible without a
versioned-load surface.

W19A introduces a versioned-load entry point. Two acceptable shapes
(the W19A coding wave picks one; both honour the §6.1 lock):

- (a) Extend `prompt_loader.load_prompt(...)` / `load_schema(...)`
  with an optional `version: Optional[str]` argument. When passed,
  the loader resolves the exact pack directory (e.g.
  `5.202605201406` or `6.YYYYMMDDHHmm`) rather than the latest.
  Callers that omit the argument see today's behavior.
- (b) Add a `floor_plan_prompt`-local `load_pack(version: str)`
  helper inside `floor_plan_prompt.py` that uses
  `_resolve_stem_in_pack` (or an equivalent local) keyed on the
  selector value, leaving the public loader API unchanged for other
  modules.

Either way, the step wrapper (§3.2) is the single source of the
selector value, and the new entry point is the **only** way the
`floor_plan_prompt` path reads `system.md` / `schema.json`. The
existing default `latest-pack` behavior of `load_prompt` /
`load_schema` is preserved for every other module so this W19A
change does not bleed into unrelated modules.

The new surface must also stamp the resolved version into
`_config_hash` (§3.2) so the checkpoint key reflects the v5/v6
choice deterministically.

## 4. Compatibility risks

### 4.1 v6 FP + un-upgraded BG = overlay leakage continues until W19B

The W19 brief contract requires BG consumers to filter
`state_overlay_*` out of their prompt body. The W19A wave does not
change the BG step. Until W19B lands, a `v6` FP + `v5/v6` BG path
will still emit transient markers in the BG prompt body **because
the BG consumer is layer-blind** (§2.5). This is acceptable for
the W19A → W19B transition window but must be documented:

- W19A must remain **flag-gated** so production traffic does not
  flip until both W19A and W19B are merged.
- Recovery semantics (running W19A alone in a staging environment)
  must understand that `numbered_elements` will gain a new field
  but downstream consumption remains layer-blind.

### 4.2 Schema additionalProperties tightening

`numbered_elements.items.additionalProperties = false` exists in v5
(schema.json line 18). Adding the `base_layer_decision` field
requires it to be listed in `required` and `properties`. There is
no consumer today that emits this field as part of an existing v5
payload (verified by repo-wide grep on `base_layer_decision` —
only `backend/scripts/experiment_floor_plan_base_layout_prompt_slice.py`
references it, and only in experiment code that does not touch
production checkpoints). Therefore no existing payload becomes
invalid under v6 schema other than v5 LLM outputs themselves, and
v5 LLM outputs only run under v5 prompt pack (config_hash gates
this — v5 checkpoint cannot be parsed under v6).

### 4.3 Checkpoint invalidation cost (opt-in only)

Under the §6.1 locked design, the runtime selector
(`settings.floor_plan_prompt_version`) carries the resolved version
into `_config_hash` (§3.2). With the default value `"5"`, the step
produces the **same** `_config_hash` as today's v5 production, so
v5 callers' existing checkpoints remain valid — **no global
invalidation occurs when the v6 pack lands on disk**.

Only projects / runs that opt into v6 by setting the selector to
`"6"` see a different `_config_hash` and therefore regenerate the
`floor_plan_prompt` checkpoint under v6. This is a per-opt-in
migration cost, not a wholesale W19A acceptance cost.

The W19 brief §3.1 still applies inside the opt-in scope: v6
strictly shrinks the FP marker set, so any v5-shape candidate (with
`category = plot_device` or without `base_layer_decision`) must be
re-generated through a v6 LLM call before its downstream render
path catches up — but the obligation is per-opt-in, not global.

### 4.4 `category = plot_device` removal break

v5 LLM outputs that returned `category = plot_device` will not
match v6 schema (`plot_device` removed from category enum). This is
intentional — the layer decision now moves to `base_layer_decision`
— but it is a hard schema break inside v5→v6 conversion. The
preferred path is to *not* migrate v5 outputs into the v6 store;
instead, regenerate v6 outputs from scratch via a fresh v6 LLM call.
No v5→v6 in-place mutator is needed.

### 4.5 Prompt-loader pack drift if stems land partial

If only some of the v6 stems (e.g. only `system.md`) land in
`6.YYYYMMDDHHmm/`, the loader silently resolves the missing stems
to v5 (lines 247–267). The W19A wave must land **all three stems
together** in a single commit to avoid stem drift. The drift would
also surface as a `logger.warning` via `_emit_pack_drift_warning`
(line 201) but never as a hard failure unless
`PROMPT_VERSION_PACK_STRICT` is set.

### 4.6 No DB row preinstall required

The loader is DB-first then file-fallback. Production today runs
file-only (the `floor_plan_prompt` module is loaded without a DB
session in `floor_plan_prompt.py:245–246`), so the v6 file pack
becomes live the moment files land on disk **if** the loader
resolves the latest pack (see §4.7 for why this is a risk). No DB
seed migration is required for W19A. The corollary: any DB row
carrying a stale v5 prompt for a different module is *not* a W19A
risk because v5 prompts in DB do not bleed across modules.

### 4.7 Prompt loader latest-pack auto-resolve risk (W19A-specific)

The existing `prompt_loader` resolves the highest-sorted version
directory and has no per-call version pinning (§1.4). If the W19A
coding wave lands the v6 prompt pack on disk **before** the
versioned-load surface (§3.6) is in place, every v5 caller silently
shifts to v6 on next process restart, violating the §6.1 default-v5
lock. This is the main W19A implementation risk called out by Codex.

Mitigation (must be enforced by the W19A coding wave):

- The v6 pack directory and the versioned-load surface (§3.6) land
  in the **same commit**. A reviewer must confirm no commit lands
  v6 pack files without the loader change.
- A unit test asserts that with `settings.floor_plan_prompt_version
  = "5"` (default), the `floor_plan_prompt` path loads stems from
  `5.202605201406/`, regardless of newer dirs on disk.
- A second unit test asserts the same with `"6"` → resolves from
  `6.YYYYMMDDHHmm/`.

This risk is **not** mitigated by the `PROMPT_VERSION_PACK_STRICT`
env flag (`prompt_loader.py:183–198`); strict mode only fires when
the latest pack is missing stems, not when an older pack is the
intended target.

## 5. Test plan

Minimal — mirroring the W18 wave's no-luxury TDD pattern. No
production code is exercised end-to-end in W19A's test suite;
the focus is on prompt pack integrity, schema validation, and
step-wrapper behavior under the new version.

### 5.1 Prompt pack integrity

- `tests/...` assert `6.YYYYMMDDHHmm/system.md`,
  `6.YYYYMMDDHHmm/user_template.md`, `6.YYYYMMDDHHmm/schema.json`
  all exist together (avoid the pack drift risk in §4.5).
- Methodology grep: the new v6 files must not contain any
  scenario-specific token (apply the same ban-list grep already used
  by the W18 experiment scripts). Static text stays generic.

### 5.2 Schema delta

- One test asserts the v6 schema's `numbered_elements.items.required`
  includes `base_layer_decision`.
- One test asserts the v6 schema's `numbered_elements.items.properties.base_layer_decision.enum`
  is exactly the 6-value set from W19 brief §2.2 — exact-set
  comparison, no lexical match.
- One test asserts `numbered_elements.items.properties.category.enum`
  does **not** contain `plot_device` (the removal in §3.1.2).
- One diagnostic test: a fixture v5-shape payload (with
  `category = plot_device`, no `base_layer_decision`) fails schema
  validation under v6.

### 5.3 Step wrapper behavior (persistence shape)

- One test asserts that under selector `"6"`, the step persists the
  new `base_layer_decision` field verbatim under each
  `numbered_elements` entry (no field stripping in the existing
  `out.get("numbered_elements") or []` path).
- One test asserts that under selector `"5"` (default), the
  persisted shape is byte-identical to today's v5 output — no
  `base_layer_decision` field introduced, no extra keys, no missing
  keys.

Selector → resolved-version mapping and `_config_hash` differential
across selectors are covered in §5.7 to avoid duplication; this
section focuses on persistence shape only.

### 5.4 Cross-field validator (version-aware)

- One test asserts that under selector `"5"` (default), a payload
  whose `numbered_elements` entries omit `base_layer_decision`
  **passes** `_validate_fp_prompt_extras`. v5 behavior is preserved
  verbatim — the v6-only requirement does not break the default
  path.
- One test asserts that under selector `"6"`, a payload missing or
  carrying an invalid `base_layer_decision` value on any
  `numbered_elements` entry **raises**.
- One test asserts that under selector `"6"`, a payload whose
  entries all carry valid v6-enum values **passes** regardless of
  mix (base-only, overlay-only, mixed).

### 5.5 Forward-compat smoke

- One test wires a fixture v6 FP checkpoint into the existing
  background_prompt helper (`_format_numbered_elements_block`) and
  asserts no exception, no change in serialized line shape (the
  extra field is ignored). This documents the W19A → W19B
  transition window contract in code.

### 5.6 Full regression parity

- `backend/tests/scripts` regression must remain at parity (605/605
  PASS at W18J closure, per W19 brief §4.5).
- W19A introduces no new experiment scripts, so the script test
  count itself stays at parity; only `backend/tests/...`
  (production-side, per §6.6 lock) picks up the new W19A unit tests
  above.

### 5.7 Versioned-load + selector default coverage

- One test asserts that with `settings.floor_plan_prompt_version =
  "5"` (default), the FP step's prompt/schema fetch resolves stems
  from `5.202605201406/` even when `6.YYYYMMDDHHmm/` is present on
  disk (mitigates §4.7).
- One test asserts the same with `"6"` resolves from
  `6.YYYYMMDDHHmm/`.
- One test asserts `_config_hash` differs between the two selector
  values (so checkpoints partition cleanly).
- One test asserts the version-aware validator (§3.3): v5-selector
  accepts a v5-shape payload (no `base_layer_decision`, with
  `category=plot_device` permitted by v5 schema); v6-selector
  rejects the same v5-shape payload.

## 6. Open decisions for Codex

### 6.1 Source-level version selection — locked

**Codex decision (locked):** option (a) — explicit version selector.
Default is `"5"` (v5 production behavior unchanged). v6 is opt-in
via the setting. Hard flip-once (option (b)) is **rejected** — it
leaves no in-band rollback path and conflicts with the default-v5
contract.

Implementation contract:

- Setting key: `settings.floor_plan_prompt_version`. Acceptable
  values: `"5"`, `"6"`. Default `"5"`.
- Both `5.202605201406/` and `6.YYYYMMDDHHmm/` pack directories live
  on disk in parallel. The selector picks which pack the step
  resolves at run time.
- The resolved version string is included in `_config_hash` so v5
  callers continue to hash identically to today (no spurious
  checkpoint invalidation).
- The version-aware load surface (§3.6) is the single read path the
  step uses to fetch prompts and schema.

### 6.2 `SCHEMA_VERSION` bump policy — locked

**Codex decision (locked):** keep `SCHEMA_VERSION = 2` for W19A
**unless** the coding wave introduces a **top-level** result-shape
change (e.g. adding a `prompt_version` field at the result root, or
moving a top-level field). Adding nested fields inside
`numbered_elements` is **not** a top-level shape change; the
selector value + `_config_hash` carries the invalidation
deterministically. Do not blindly bump `SCHEMA_VERSION` just for the
v6 pack.

If the W19A coding wave does introduce a top-level shape change, the
PR description must call it out explicitly and `SCHEMA_VERSION` is
bumped in the same patch.

### 6.3 `category = plot_device` — locked: remove

**Codex decision (locked):** `plot_device` is **removed** from the
v6 schema's `numbered_elements.items.category` enum. Overlay markers
are expressed by `base_layer_decision = state_overlay_*`, not by
`category = plot_device`. The schema constraint (input shape closed)
is the right safety net; deprecation-without-removal is rejected.

### 6.4 Dominant target unit shape — W19A non-blocking; W19B decision

**Codex decision (locked):** W19A is **not** blocked on this
question. The FP step does not consume per-bg payload (§3.x). The
W19 brief §7 #6's three candidates remain on the table for W19B
design; the **preferred future shape** is (c) — an explicit
`dominant_target_unit_id` field on the per-bg payload — but the
W19A coding wave does not need to commit to it. This decision is
deferred to the W19B preflight.

### 6.5 Per-bg overlay payload location — locked: separate new step

**Codex decision (locked):** option (b) — the per-bg overlay payload
is produced by a **new step that runs between `floor_plan_prompt`
and `background_prompt`**, not by an additional LLM call inside the
FP step. The W19A wave **must not** add a second LLM call to
`floor_plan_prompt`; the FP step keeps its single-LLM-call shape.

Implication for W19A: the FP step's output shape does **not** gain
per-bg payload fields. The W19A v6 schema only changes
`numbered_elements` (per-marker `base_layer_decision`); per-bg
artifacts wait for the new W19B step. The naming for that new step
is a W19B preflight decision (candidate: `floor_plan_overlay_payload`
or similar generic name; the W19A coding wave does not commit to
it).

### 6.6 Test placement — locked: production-side

**Codex decision (locked):** the W19A unit tests live under
`backend/tests/` at the production module's appropriate path (e.g.
`backend/tests/app/modules/pipeline/test_floor_plan_prompt_v6.py`
and `backend/tests/app/core/steps/test_floor_plan_prompt_step_v6.py`,
or the project's conventional production-test layout). They do
**not** live under `backend/tests/scripts/` (which is the experiment
script test surface). The W19A coding wave is the first wave that
touches production code; its tests belong to the production-side
test layout.

## 7. Guard confirmation

Verified at this preflight's authoring moment (2026-05-26):

- `git diff --stat backend/app backend/alembic` — **empty**
  (production code / migrations diff = 0).
- No image / LLM / VLM API call issued in this preflight session.
- No DB write. No ImageAsset write. No commit. No push.
- No new experiment script run. No PNG regeneration. No other fp
  expansion.
- The W19 brief
  (`2026-05-26-w19-floor-plan-background-migration.md`) and this
  preflight document both remain untracked under
  `docs/superpowers/specs/` — working tree only, no `git add` / `git commit`.

This preflight is **document-only**. The W19A code wave will not
begin until Codex approves §3 / §5 / §6 above.
