# Phase 6: Flow Tree Runtime

**Status:** Draft, pre-implementation. Built iteratively chunk-by-chunk.
**Depends on:** Phase 5 (derivation + defs) landed (commits through master `1affce5`).
**Reference:** `docs/ui_flow_tree_runtime_spec.md` is the design source of
truth. This document is the implementation spec — it locks
implementation-level decisions (package layout, loader patterns,
chunking, validation rules, acceptance) that the design spec leaves open.

---

## 1. Purpose

Stand up the flow tree runtime — the container, interpreter, and
interaction schema contract for the declarative UI system. This
phase delivers:

1. **Tree runtime** — `TreeManager`, mode roots, parent-child
   hierarchy, focus path, root-arbitrated policies, ModeSession
   reshape (chunk 8 D122 — runtime owns its own state; ModeSession
   becomes a per-mode CanSave carrier, not a delta-serialization
   target), `HandleFrame` integration.
2. **Interaction mechanics** — per-node interpreter, directives
   (`next` / `complete` / `cancel` / `host_effects`), outcomes,
   errors, `OpenContext`, snapshot/rollback.
3. **Lua component evaluation + Go bridge** wired into the live
   frame path (Phase 5 components were test-only; this phase
   plugs them into `HandleFrame`).
4. **Mode root schemas** for all three modes (battle, title,
   overworld), authored in Lua.
5. **Battle proof** — persistent passive (party_status) +
   interactive (command_panel) + transient (pause_menu). Real data
   from Phase 5 derivation, real components, real `command.CommandRequest`
   round trip.
6. **Replacement** of `game/menu/` and `game/flow/` packages.

This phase consumes the definitions and derivation infrastructure
landed in Phase 5 and reuses them as-is — it does not re-open
their mechanism.

## 2. Scope fence

### Phase 6 delivers
- The runtime mechanism (tree manager, interpreter, render path,
  policies) end-to-end. Per chunk 8, `ModeSession` is reshaped
  to a per-mode CanSave carrier rather than a delta-serialization
  target — runtime state lives in TreeManager + Interpreter and
  is not mirrored into ModeSession.
- One mode root per game mode (battle, title, overworld), authored.
- One persistent passive proof (party_status), one persistent
  interactive proof (command_panel), one transient interactive
  proof (pause_menu).
- The bridge from `command_panel` outcome through
  `command.CommandQueue` to a registered `command.CommandHandler`.
- Deletion of `game/menu/` and `game/flow/` once parity is
  established.

### Phase 6 does NOT deliver
- The full component library or kitchen sink (per design spec §17).
  Components are authored incrementally as runtime proofs need them.
- Full battle combat flows (per design spec §17). The battle proof
  exercises the round trip; combat content is later.
- Re-opening the Phase 5 mechanism (defs registry, derivation VM,
  component evaluator). Those are reused exactly.
- Engine-side animation, layout, query, or text changes. Phase 6
  consumes `engine/ui/anim`, `engine/ui/query`, `engine/ui/style`,
  `engine/text` as-is.
- A new save format. The existing `SaveFile{Version, Metadata,
  ECSWorld}` shape (`docs/systems/save_load.md` §b) is preserved.
  Chunk 8 D124 retrofits the F5 save-callback gate to use the
  new two-layer save policy (`session.CanSave()` mode-level +
  `policy.CanSave(tm)` tree-aggregated) but does not extend the
  save file format. Runtime state is not serialized; the gate
  ensures saves only happen in clean states (no transient flow
  open, no `save_suppress` violations) so the load path's
  fresh-runtime-on-overworld-boot continues to work.

### Iteration discipline
This spec grows chunk-by-chunk. Each chunk's D-list, acceptance,
and implementation notes are added once the prior chunk is
committed and reviewed. Cross-cutting decisions in §3 apply to
all chunks; per-chunk decisions are added in their own subsections
under §4 onward.

## 3. Cross-Cutting Decisions (D-list)

### D1. Authority split — game/interaction owns, engine/ui stays primitive

Per design spec §14, the flow tree runtime lives in a new
`game/interaction/` package tree. Engine UI stays primitives,
layout, query, animation, text, style. The tree manager imports
engine UI types but engine UI never imports `game/interaction`.

`game/interaction/` (broad guidance; per-subpackage rules narrow
this list per their needs — see below for the subpackage override
rule) is allowed to import:
- `engine/coretypes`, `engine/assets`, `engine/ecs`, `engine/script`
- `engine/ui/core` (NodeID, primitive types), `engine/ui/anim`,
  `engine/ui/query`, `engine/ui/style`
- `engine/text`, `engine/command`, `engine/session`
- `engine/input` (frame state types)
- `engine/pipeline` — **interpreter only** per chunk 4 D47.
  `pipeline.HostRequest` is the canonical host-effect type that
  `FlowOutcome` references (design spec §13). Schema and tree
  subpackages still forbid pipeline.
- `failures`, `gopher-lua`
- `game/defs`, `game/ui/components`, `game/ui/derivation`,
  `game/ui/bridge` (per-subpackage rules layered in as chunks land)
- `game/mode` — **interpreter only** per chunk 4 D47.
  `mode.GameMode` is the canonical mode-transition target that
  `FlowOutcome.Transition` references (design spec §13). Schema
  and tree subpackages still forbid game/mode.

`game/interaction/` is forbidden from importing:
- `engine/runtime`, `engine/sim/*` (excluding cue data types as
  needed), `engine/world`, `engine/presentation`, `engine/overlay`,
  `engine/animation` (different package from `engine/ui/anim`),
  `engine/display`, `engine/errorsink`, `engine/clock`, `engine/ds`,
  `engine/fs`
- `render/*`, `platform/*`
- Other `game/*` siblings (`game/menu`, `game/flow`,
  `game/proposals`, `game/snapshot`, `game/uidef`, etc.)
- `afterimage/game` root

**Subpackage rules can both narrow (add forbidden items) and
broaden (remove forbidden items the cross-cutting list set),
case-by-case per chunk D-list rationale.** The chunk 4 interpreter
relaxes the parent-level forbids on `engine/pipeline` and
`game/mode`; the schema and tree subpackages keep the
parent-level forbids. Each chunk that introduces a subpackage
adds its own import-guard rule.

### D2. Package layout

```
game/interaction/
  schema/         (Chunk 1) — schema language, three-profile loader
  tree/           (Chunk 2) — TreeManager, FlowNode, parent-child
  render/         (Chunk 3) — render path, component+bridge wiring
  interpreter/    (Chunk 4) — ProcessInput, directives, FlowState
  routing/        (Chunk 5) — focus path, RouteInput, navigation
  policy/         (Chunk 6) — pause/save/suppression aggregation
  animation/      (Chunk 7) — enter/exit lifecycle, reconciliation hooks
  (Chunk 8 doesn't add a new subpackage — it reshapes
   game/sessions.go in the root `game/` package instead.)
```

Subpackage names are provisional; later chunks may merge or split.
Chunk 1 only commits to `game/interaction/schema/`.

### D3. Lua asset layout

```
game/assets/scripts/game/
  interaction/
    schemas/                    (NEW — Chunk 1)
      battle_root.lua
      title_root.lua
      overworld_root.lua
      party_status.lua          (passive)
      pause_menu.lua            (interactive)
      <command_panel.lua, etc. — added in later chunks>
  defs/                         (Phase 5, existing — reused)
  ui/
    components/                 (Phase 5, existing — reused)
    derivations/                (Phase 5, existing — reused)
```

Asset key convention for schemas:
`script.game.interaction.schemas.<name>` — flat (single segment
after the prefix), same rule as Phase 5 derivations.

### D4. Three-profile schema language

Per design spec §8, schemas come in three profiles: **root**,
**interactive**, **passive**. The schema language is a single
authored shape with optional sections; profiles are detected at
load time from which optional sections are present:

- **Root** — has `persistent_children` and/or `mode_save_policy`
- **Interactive** — has `interactive = true` (and `steps`)
- **Passive** — neither root nor interactive

Cross-profile combinations (e.g., `interactive = true` with
`persistent_children`) are validation errors at load time.

### D5. Schema function context — `ctx.view` / `ctx.params` / `accum` only

Per design spec §2.6 and §7.4, schema functions receive:

- `ctx.view` — read-only view_state (derived context output)
- `ctx.params` — immutable open-time parameters
- `accum` — mutable workspace (subject to JSON-like Lua subset)

Schema functions do **not** receive `ctx.defs`. Definitions
flow through derivation into view_state; if a step needs
defs-derived data, derivation puts it in `view_state` and the
schema function reads `ctx.view`. This preserves the Phase 5
three-layer boundary (defs → derivation → view_state → schema fn).

This rule is invariant across all interactive chunks (4 and
later). It applies to `options_fn`, `summary_fn`, `on_select`,
`on_confirm`, `on_complete`, `on_cancel`, and any other authored
schema function.

### D6. Identity model

Per design spec §2.7:

- **Persistent nodes** — deterministic IDs from tree position:
  `{parent_id}.{child_schema}`. Index suffix
  `{parent_id}.{child_schema}.{index}` when a parent has multiple
  children with the same schema.
- **Transient nodes** — runtime-generated IDs, monotonic
  per-mode-root counter. Prefix is the parent's instance_id;
  format `{parent_id}.<schema>:<counter>`. Counter resets per
  mode start.

The exact counter implementation is locked in chunk 2 when the
tree manager attaches transient children.

### D7. Identity bridge — instance_id scopes UINode IDs

Per design spec §2.8:

A flow node's `instance_id` is the prefix scope for all UINode IDs
its component emits. Child UINode IDs within that subtree are
derived as `JoinNodeID(instance_id, ..., local)`.

Animation keys, focus restoration, scroll retention, and spatial
queries all key on UINode IDs scoped under stable persistent
instance_ids — so they survive tree rebuilds.

### D8. Animation reconciliation — animator owns logic, TreeManager.Render owns call site

`engine/ui/anim.Animator.Reconcile(nodeID, declared)` already
exists (Phase 4). Phase 6 does not implement reconciliation
logic. Phase 6 adds the call site.

Per `docs/ui_flow_tree_runtime_spec.md` §12.3, `TreeManager.Render`
walks the visible tree, evaluates each node's component (yields
`ComponentOutput{Tree, Animations}`), runs the bridge to translate
the primitive tree into UINode subtrees (pure translation), and
calls `animator.Reconcile(nodeID, declared)` for each node's
declared animations.

The Go bridge is **not** an interpreter of animation declarations.
It does pure primitive→UINode translation and nothing else.

### D9. Bridge purity contract

The primitive→UINode bridge (existing in `game/ui/bridge`,
extended/wired in chunk 3) must be:

- Deterministic: same input, same output. No animator state
  reads, no time-of-frame reads.
- Pure: no animator side effects, no command-queue interactions,
  no component re-evaluation.
- Independent of frame state: callable from tests with no live
  animator or tree manager.

Reconciliation, command emission, and frame-state mutations live
in the tree manager and interpreter, not the bridge.

### D10. Phase 5 reuse — no re-opening

Phase 6 consumes Phase 5 outputs **as-is**. No new constructors,
no signature changes to existing methods, no behavioral
re-interpretation. **Tiny additive read-only / allocation
accessors** (e.g., `HasModule(id) bool`, `NewTable() *lua.LTable`)
are allowed when they prevent fragile error-code matching or
duplicate auxiliary state in Phase 6 callers; each such addition
must be explicitly named in the chunk's D-list with rationale.

- `game/defs.Registry` — `Load`, `AssembleDefsTable`, `Close`.
  Used by chunks 2+ when derivation runs.
- `game/ui/derivation.Registry` and `DerivationVM` — `Load`,
  `EvaluateDerivation`. Used by chunks 2+ for `view_state`.
- `game/ui/components.Registry` and `NewComponentVM` (returns
  `*lua.LState`) — `Load`, `EvaluateComponent`. Used by chunk 3
  for the render path.
- `engine/ui/anim.Animator` — `Start`, `StartGroup`,
  `StartSequence`, `Reconcile`, `Apply`, `Sweep`, `Advance`.
  Used by chunks 3 (reconcile), 7 (enter/exit lifecycle).
- `engine/command.CommandQueue` and `CommandHandler` — used by
  chunk 6 (HandleFrame integration) for outcome dispatch.
- `failures.Rejectf`, `failures.WrapPhase`, `failures.ValidationError`
  — used everywhere validation produces errors.

If a chunk hits a Phase 5 limitation that can't be worked around
without changing Phase 5 surface, that's a stop-and-discuss
moment. Default is reuse.

### D11. Cutover strategy — new path behind switch, delete old after parity

`game/menu/` and `game/flow/` carry the live UX today. They will
be deleted in this phase. The cutover sequence:

- **Chunks 1-3** — schema + tree runtime + render mechanics stand
  up. No HandleFrame changes. Old menu/flow path keeps running;
  new code is test-only. (Chunk 3 originally scoped the live
  switch but deferred it per D45 — see below.)
- **Engine surface alignment chunk** (TBD chunk 3.5 or folded
  into chunk 6) — exposes the animator on `engine/pipeline.FrameContext`
  so HandleFrame can drive `TreeManager.Render`. This is the
  prerequisite that lets the live cutover switch land. Without
  it, HandleFrame cannot reach the animator.
- **Cutover switch chunk** (the same chunk that lands the
  surface alignment) — wires the per-mode flag and the
  HandleFrame branch. Default OFF for all modes.
- **Chunks 4-7** — switch covers more cases as capabilities
  land (interactive, focus, policies, animations). New path
  reaches parity for title and battle modes.
- **Chunk 8** — `game/menu/` and `game/flow/` deleted; switch
  and any adapter/shim removed; ModeSession reshape (CanSave
  added; legacy menu+flow fields deleted; runtime owns its own
  state directly) and F5 save-callback gate retrofit (§docs/
  systems/save_load.md §d).

Carrying both runtimes side-by-side is intentional during the
cutover-window chunks to keep the game playable while the new
runtime matures. Chunk 8's job is to remove the side-by-side
scaffolding in one diff.

The "where exactly the engine surface alignment + switch lands"
question is intentionally loose at this writing — see D13 for
the known deferred work, and the §7 chunking sketch for the
nominal carve.

### D12. Battle proof bar — real CommandQueue round trip

The battle proof in chunk 6 (or where command_panel lands —
finalized in that chunk's spec) exercises:

1. Player input routes through `TreeManager.RouteInput` to the
   `command_panel` flow node.
2. `command_panel`'s `on_complete` returns
   `{ commands = { { type = "battle_action", payload = accum } } }`.
3. The interpreter produces a `FlowOutcome` with that
   `[]command.CommandRequest`.
4. `dispatchOutcomes` (in the HandleFrame integration) calls
   `CommandQueue.Enqueue` for each, stamping tick/seq.
5. A registered `CommandHandler.Apply` receives the stamped
   `Command` during the next sim tick. The handler can be
   minimal — assert shape and write a single ECS field, or
   even no-op — but the round trip is real, not mocked.

Per design spec §17, full combat flows are out of scope. The
proof is the round trip, not the gameplay.

### D13. What Phase 6 does not include

- No new component types beyond what proofs need (party_status
  component already exists from Phase 5; `command_panel` and
  `pause_menu` components added in their landing chunks).
- No new derivation namespaces beyond what proofs need
  (`party_status` derivation already exists from Phase 5;
  `command_panel` derivation added with command_panel proof).
- No new defs content beyond what proofs need
  (`defs.ui.party_status` already exists; additional defs added
  with their consuming chunks).
- No save format changes. Chunk 8 reshapes the per-mode
  `ModeSession` types (`TitleSession` / `OverworldSession` /
  `BattleSession`) — legacy menu+flow fields removed, CanSave
  added — but the on-disk `SaveFile` shape (Version, Metadata,
  ECSWorld) is preserved. Runtime state is not serialized; the
  retrofit save-callback gate ensures saves only occur in clean
  states (D124).
- No engine UI subsystem changes. `engine/ui/anim`,
  `engine/ui/query`, `engine/ui/style`, `engine/text`,
  `engine/command` are consumed as-is.
- **One known engine/pipeline surface change is required and
  deferred:** `engine/pipeline.FrameContext` does not currently
  expose the animator, but `TreeManager.Render` needs animator
  access to call `Reconcile`. The chunk that wires the live
  HandleFrame integration must add an animator field to
  `FrameContext` (or otherwise route the animator into game-side
  HandleFrame). This is not optional — it is a known deferred
  Phase 6 surface change. The exact landing chunk is loose
  (see D11; likely chunk 6 or a dedicated alignment chunk
  inserted before chunk 4). Spec acknowledges this gap so it
  doesn't surprise a later chunk.
- No other `engine/runtime` or `engine/pipeline` surface
  changes are anticipated. If a chunk hits one, that chunk's
  spec calls it out explicitly.

---

## 4. Chunk 1 — Schema Language + Three-Profile Loader

**Status:** Draft, pre-implementation.
**Goal:** Stand up the schema language and the loader. No
runtime, no interpreter, no rendering. All later chunks build on
the loaded metadata.

### 4.1 Chunk 1 decisions (D14–D21)

#### D14. Schema loader — private LState, ephemeral, metadata-only

`game/interaction/schema.Registry` mirrors the Phase 5
`game/ui/derivation.Registry` pattern:

- `Load(catalog, scripts) (*Registry, error)` enumerates catalog
  descriptors with prefix `script.game.interaction.schemas.`,
  validates each is flat (no `.` after prefix), loads each
  module's source directly into a private loader `*lua.LState`
  (see below for source-loading mechanics), validates the returned
  table per-profile, extracts metadata into Go structs, and
  returns the registry. Loader VM is `defer L.Close()`d before
  `Load` returns.
- **Direct source loading, no Lua `require`.** Each module is
  loaded by reading its source via `scripts.RequireScript(key)`
  to obtain a `ScriptSnapshot.Value.Source` string, then executed
  via `L.LoadString(source)` + `L.CallByParam(...NRet: 1...)`.
  Lua's `require` mechanism is **not** used; `engine/script.InstallRequire`
  is **not** installed. The asset reader is the only source-loading
  mechanism.
- Rationale: the import guard forbids `engine/script` from
  `game/interaction/schema`; schemas are self-contained
  (`return { ... }`); shared helpers between schemas are not in
  Phase 6 scope. If a future chunk introduces shared schema
  helpers, that chunk extends the loader profile and may need to
  open `engine/script` access.
- `Lookup(schemaID) (*Schema, bool)` — constant-time map read.
- `IDs() []string` — sorted, deterministic.
- No `Close` method on `Registry`. Once `Load` returns, the loader
  VM is gone and the registry holds only Go-side data; nothing to
  release.

#### D15. Cross-VM discipline — function refs do not survive loader

`*lua.LFunction` values cannot cross LState boundaries. The
schema loader VM is ephemeral. Therefore the Registry retains
**no function references**, only:

- Profile classification + common-field metadata
- Step graph topology (step IDs, types, `back` targets)
- Static `options` arrays for `choice` steps when authored
  statically
- Animation declarations in `[]anim.DeclaredAnimation` form
  (these are plain data — `Key`, `Animation*` / `Sequence*` —
  copied out of the loader VM during extraction)
- Navigation routes (root profile)
- Asset key, for re-loading in a runtime VM later
- Boolean flags recording function-field presence
  (`HasOnComplete`, `HasOnSelect`, etc.) — used for validation

Function references are re-acquired by the chunk 4 interpreter
VM, which loads each schema's source from the same asset key
(via the same direct-source-loading path the chunk 1 loader uses,
or via `engine/script.InstallRequire` if chunk 4 chooses to use
the require system — that's a chunk 4 decision, not chunk 1's).
Static option lists that are *also* read at runtime can either
be re-read by the interpreter VM (preferred — single source of
truth) or trusted from the registry's snapshot. Chunk 4's spec
locks this.

#### D16. Step type taxonomy — `choice` and `confirm`

Phase 6 starts with two step types:

- **`choice`** — picks one of N options. Validated fields:
  - Either `options` (array of `{key, label}` with non-empty
    unique keys) or `options_fn` (a function), exclusive.
  - `on_select` — required function.
  - `back` — optional; if present must be `"cancel"` or a step
    ID present in the same schema's `steps` table.
- **`confirm`** — prompts yes/no on a summary. Validated fields:
  - `summary_fn` — required function.
  - `on_confirm` — required function.
  - `back` — optional; same rule as `choice`.

Unknown `type` values are validation errors. Additional step
types (e.g., `info`, `text_input`) are not in Phase 6 scope unless
a later chunk's proof needs them — and any addition extends both
the schema loader's validator and the interpreter dispatcher in
the same chunk.

Step types are validated **structurally only** in chunk 1. The
interpreter dispatch (chunk 4) and component mapping (chunk 3)
land later.

#### D17. Validation deferrals to later chunks

Chunk 1 deliberately does **not** validate:

- **Cross-schema references** — `persistent_children` entries and
  `navigation.from`/`navigation.to` may name schema IDs that don't
  exist yet. The tree manager (chunk 2) catches unresolved
  children when instantiating persistent children at mode start.
  This means chunk 1 can author `battle_root` referring to
  `command_panel` even though `command_panel.lua` is not authored
  until a later chunk.
- **Anchor `kind` enum** — `anchor = { kind = "..." }` is
  validated as "non-empty string" only. The renderer (chunk 3)
  locks the kind enum.
- **Step graph reachability** — whether every step is reachable
  via `next` directives is a runtime concern; chunk 1 only
  validates `initial_step ∈ steps` and `back ∈ steps ∪ {"cancel"}`.
- **`on_complete` / `on_cancel` semantics** — chunk 4 locks
  outcome shapes. Chunk 1 only verifies they are functions if
  present.

#### D18. ID-equals-asset-key-remainder rule

Each schema's `id` field must equal the catalog asset key
remainder after the `script.game.interaction.schemas.` prefix.
Mismatch is a validation error (`opSchemaIDMismatch`).

Rationale: prevents two-way drift between filesystem authoring
and Lua-table authoring; mirrors Phase 5 derivation's flat-name
rule.

#### D19. engine/ui/anim is an allowed import for `game/interaction/schema`

The schema package imports `engine/ui/anim` to consume the
existing `DeclaredAnimation`, `Animation`, and `Sequence` types
when extracting `enter_animations` / `exit_animations` from
authored Lua tables.

Alternative considered: define a local mirror type in the schema
package and convert at runtime. Rejected — unnecessary plumbing,
loses type identity for downstream animator calls.

This dependency is one-way: `engine/ui/anim` does not import
`game/interaction/*`.

The related leaf packages `engine/ui/anim/easing` and
`engine/ui/style` are also allowed: `easing` because
`anim.Animation.Easing` is `easing.EaseKind` and the layer guard
is deny-list (any sub-path of `engine/ui/anim` not explicitly
forbidden is allowed); `engine/ui/style` because
`anim.Animation.FromColor` / `ToColor` are `style.RGBA`, and
chunk 1 parses the full `anim.Animation` shape (color-property
animations are not authored in the chunk 1 schemas, but the
extractor must handle them so chunk 7's consolidated
`game/ui/animlua` package does not have to re-do the parser).
The §4.3.5 layer guard rule reflects this: `engine/ui/style` is
**not** in the forbidden list.

#### D20. Schema-level enter/exit animations — lifecycle-imperative, no target field

Two animation surfaces exist in the design with **different
call sites**:

- **Schema-level** (`enter_animations` / `exit_animations` on
  the schema common fields) — played imperatively at lifecycle
  transitions. The tree manager calls
  `animator.Start` / `StartGroup` / `StartSequence` /
  `StartSequenceGroup` once when the node becomes visible (enter)
  or starts closing (exit). Exit reaping polls
  `animator.AreKeysComplete(nodeID, keys)` to determine when the
  node can be detached. **`Reconcile` is not used for schema
  animations.** These are one-shots / sequences started at the
  transition edge, not per-frame state-driven declarations.
  The actual lifecycle call sites land in chunk 7.
- **Component-declared** (Phase 4, existing) — flow-node-level
  components return `(tree, animations)` where each animation
  has a `target` relative to the component's emitted subtree
  (e.g., `"fighter_" .. f.id .. "/hp_bar"`). Resolved during
  `components.EvaluateComponent`. Reconciled per frame against
  the animator (D8). **Not parsed by the schema loader.**

Chunk 1 parses only the schema-level surface. The extractor
reads `enter_animations` and `exit_animations` arrays into
`[]anim.DeclaredAnimation` where each entry has `Key`,
`Animation*` or `Sequence*`, and no target field. If an authored
schema includes a `target` field on a schema-level animation,
the validator rejects it
(`opSchemaAnimationTargetNotAllowed`).

**Open question deferred to chunk 7:** which `nodeID` does the
tree manager pass to `animator.Start` for schema-level
animations? The flow node's `instance_id` is the **prefix scope**
for emitted UINode IDs (D7), not the emitted root UINode ID
itself. The Phase 4 bridge composes IDs as
`scope/instance/localID`, so a flow node's emitted root is
something like `<scope>/<instance_id>/<component_root_local_id>`
(e.g., `battle/party_status/root`), determined by the component's
authored root local ID. Two candidate resolutions:

1. The tree manager wraps each flow node's emitted subtree in a
   synthetic root UINode with ID = `instance_id`, and schema
   lifecycle animations attach to that.
2. The tree manager remembers `ComponentOutput.Node.ID` after each
   render and uses that translated root ID as the lifecycle
   animation `nodeID`.

Chunk 7 locks this. Chunk 1 must not bake in either assumption —
the registry retains the declared animations as data only; no
nodeID is computed at chunk 1 time.

#### D21. Sequence-form animation handling

Animation declarations come in two forms (Phase 4):

- **Single animation** — `from`, `to`, `duration`, `easing`,
  `mode`. **Chunk 1 supports this form fully and is required.**
- **Sequence** — `keyframes` array. If chunk 1's implementer
  finds the existing Phase 4 sequence parser directly reusable,
  sequences are supported in chunk 1. Otherwise sequence-form
  support is deferred to chunk 7 (animations) and the implementer
  must loudly surface that deferral in their chat-level summary.

The five authored schemas in chunk 1 do not require sequence
form. Adding sequence support is purely a question of whether
to land it now or later.

#### D22. Typed pause/save/mode-save constants

Chunk 1 materializes the policy-string enums as typed Go
constants in `game/interaction/schema`. Later chunks reference
these constants directly (e.g., chunk 6's policy aggregation
switches over `schema.PauseNever` / `schema.PauseWhileVisible`
/ `schema.PauseWhileFocused` / `schema.PauseWhileInput`; chunk
8's save gate compares `rootSchema.Root.ModeSavePolicy ==
schema.ModeSaveAllowed`). Without typed constants in chunk 1,
those chunks would either retrofit the enum or compare against
magic strings.

The package exports:

- `PauseRequestKind` (uint8) with constants `PauseNever`,
  `PauseWhileVisible`, `PauseWhileFocused`, `PauseWhileInput`.
  Used for `CommonFields.PauseRequest`.
- **`SaveSuppress` reuses `PauseRequestKind`.** Per design
  spec §2.3 / §2.4, `pause_request` and `save_suppress` share
  the same four-value enum (`never` / `while_visible` /
  `while_focused` / `while_input`); the chunk 6 spec text
  comments this reuse explicitly. `CommonFields.SaveSuppress`
  is `PauseRequestKind`.
- `ModeSavePolicyKind` (uint8) with constants `ModeSaveNever`,
  `ModeSaveAllowed`. Used for `RootSchema.ModeSavePolicy`.

Authored string forms map exactly to the constants above;
unknown values are validation errors rolled up under
`opSchemaInvalid` (`Path` = `pause_request` / `save_suppress`
/ `mode_save_policy`). The defaulting rules in §6 of the chunk
1 prompt apply to the typed values: the `PauseNever` zero
value is the natural default (matches root and passive
defaults; interactive transient explicitly sets
`PauseWhileVisible`).

### 4.2 Chunk 1 acceptance

#### 4.2.1 New files

```
game/interaction/schema/
  types.go         — Schema, RootSchema, InteractiveSchema, PassiveSchema,
                     Step, ChoiceStep, ConfirmStep, ChoiceOption,
                     NavigationRoute, FocusEntry, Anchor + enum types
  registry.go      — Registry, Load, Lookup, IDs
  validate.go      — per-profile validation logic
  extract.go       — pulls Go metadata from a Lua schema table
  errors.go        — op constants
  types_test.go
  registry_test.go
  validate_test.go
  extract_test.go

game/assets/scripts/game/interaction/schemas/
  battle_root.lua
  title_root.lua
  overworld_root.lua
  party_status.lua
  pause_menu.lua
```

#### 4.2.2 Modified files

- `internal/importguard/import_guard_test.go` — add the
  `game/interaction/schema` rule (uses `ExactImport: ["afterimage/game"]`
  for the root-package exclusion, mirroring Phase 5 chunk 2's pattern).
- `game/assets/catalog.json` — regenerated by `tools/cataloggen/`
  (do not hand-edit).

#### 4.2.3 Test matrix

`registry_test.go`:
- All authored schemas load successfully with expected profile + ID.
- Nested-directory asset key rejected (`opSchemaNestedDirectory`).
- Module returning non-table rejected (`opSchemaNotATable`).
- `id` mismatching asset key remainder rejected
  (`opSchemaIDMismatch`).
- Multiple invalid schemas: `Load` returns `errors.Join` of all
  failures.
- `Lookup` hit and miss.
- `IDs()` returns sorted, stable order.

Note on duplicate IDs: `opSchemaDuplicate` is not reachable under
the asset-catalog uniqueness invariant + D18 ID-equals-remainder
rule. Two schemas can only share an ID if both fail D18 in
correlated ways, which D18's per-schema check already catches.
Implementers may include a defensive map check in `Load` if it
falls out cleanly, but it is not a test contract.

`validate_test.go`:
- Profile detection: root, interactive, passive.
- Common field defaults: `focusable` defaults to `interactive`,
  `pause_request` defaults per profile, etc.
- `focus_entry = "named:<id>"` parses; empty name rejected.
- Root profile: `persistent_children` non-string rejected,
  navigation direction enum, `from`/`to` not in children rejected,
  duplicate `(from, direction)` rejected, `mode_save_policy`
  required + enum-validated.
- Interactive profile: `initial_step` missing or unknown rejected,
  step `back` unknown rejected, `back = "cancel"` accepted, choice
  step options/options_fn XOR, neither rejected, duplicate option
  keys rejected, `on_select` missing rejected, confirm step
  `summary_fn` / `on_confirm` missing rejected, unknown step type
  rejected.
- Passive profile: extraneous fields (steps, on_complete, etc.)
  rejected.
- Profile conflict: `interactive = true` with `persistent_children`
  rejected.
- **Single-animation schema declarations** parse into
  `[]anim.DeclaredAnimation` with `Animation` populated and
  `Sequence` nil. **Required.**
- **Schema-level animation `target` field rejected**
  (`opSchemaAnimationTargetNotAllowed`) — schema enter/exit are
  root-scoped per D20.
- **Sequence-form schema declarations** parse with `Sequence`
  populated and `Animation` nil. **Required only if D21 sequence
  support is implemented this chunk; omitted otherwise.**
- Anchor: `kind` required and non-empty.

`extract_test.go`:
- Boundary tests for the extractor: missing required common
  fields, type mismatches on each common field, etc.

`types_test.go`:
- `FocusEntry` round-trip parse — light coverage.

#### 4.2.4 Integration proof

Chunk 1 has no integration test. The "five authored schemas all
load" assertion in `registry_test.go` is the integration proof for
this chunk. Tree-level integration begins in chunk 2.

#### 4.2.5 Validation gate

```
go run ./tools/cataloggen/ -check
go test ./game/interaction/schema/...
go test -tags guard ./internal/importguard/...
go run ./tools/devctl testall
```

All four must pass.

### 4.3 Chunk 1 implementation notes

#### 4.3.1 Loader VM profile

The loader VM is the minimum needed to execute a `return { ... }`
module that may contain function literals:

```go
L := lua.NewState(lua.Options{SkipOpenLibs: true})
defer L.Close()
```

No standard libs (`OpenBase` / `OpenTable` / `OpenString` /
`OpenMath`). No `engine.query`, no `engine.fixed`. No
`engine/script.InstallRequire`. No
`DisableMathRandom`/`DisableDangerousGlobals` (those exist to
sandbox VMs that *execute* authored functions; the loader only
validates structure and never calls them).

Rationale: the five authored schemas in chunk 1 are pure
`return { ... }` constructors. Function literals inside them are
constructed as closures at load time but are not invoked, so
their bodies' references to (e.g.) `table.insert` or
`string.format` do not need stdlibs available at load. If a
future schema needs a stdlib at top level (e.g., for dynamic
option construction at module load), that chunk extends the
profile.

Each schema is loaded by:

```go
snap, err := scripts.RequireScript(key)            // ScriptSnapshot
fn, err := L.LoadString(snap.Value.Source)
err := L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true})
val := L.Get(-1)
L.Pop(1)
```

The returned value is asserted to be a `*lua.LTable`; non-table
is `opSchemaNotATable`. Lua's `require` is not used.

#### 4.3.2 Extraction pattern

`extract.go` reads the Lua table via gopher-lua's Go-side
accessors (`RawGetString`, `ForEach`) and produces Go structs.
No cross-VM Lua calls; same pattern as Phase 5's `deepCopyLuaTable`.

For function fields (`on_select`, etc.), extraction records
**presence** as a boolean and verifies the Lua value is an
`*lua.LFunction`. The function reference is not retained.

For static `options` arrays, extraction reads each entry as a
Lua subtable, asserts `key` and `label` are strings, and copies
into `[]ChoiceOption`.

For animation declarations, extraction walks the array and
constructs `anim.DeclaredAnimation` values directly. **Re-implement
locally in the schema package** — the chunk 1 layer guard forbids
`game/ui/components`, so any existing Phase 4 extractor cannot be
imported here. The local implementation may duplicate Phase 4
logic; surface that duplication in the chat-level summary so
chunk 7 can consolidate (e.g., by lifting the shared parser into
a types-only sibling package both schema and components can
import).

#### 4.3.3 Validation pattern

`validate.go` runs per-profile validators against the extracted
Go struct. Each violation produces a
`failures.ValidationError{Path, Message}`. Multiple violations
in one schema are collected and returned via
`failures.WrapPhase(opSchemaInvalid, errs)`. When multiple
schemas in the same `Load` call fail (each with its own
`WrapPhase` error), `Load` aggregates them via `errors.Join`.

`Path` strings are dotted relative to the schema root:
`steps.main.options[2].key`, `navigation[1].direction`, etc.

#### 4.3.4 Authored Lua content

Verbatim contents of the five `.lua` files are listed in the
chunk 1 prompt (not in this spec, to keep the spec's
authoritative content shape compact). The `pause_menu.lua` content
mirrors design spec §11.1 exactly — implementer copies, does
not paraphrase.

#### 4.3.5 Layer guard rule

```go
{
    ImporterBase: modulePath + "/game/interaction/schema",
    ForbiddenBase: []string{
        modulePath + "/engine/runtime",
        modulePath + "/engine/pipeline",
        modulePath + "/engine/ui/core",   // direct UINode reach not allowed yet
        modulePath + "/engine/ui/query",
        modulePath + "/engine/sim",
        modulePath + "/engine/world",
        modulePath + "/engine/presentation",
        modulePath + "/engine/overlay",
        modulePath + "/engine/text",
        modulePath + "/engine/input",
        modulePath + "/engine/display",
        modulePath + "/engine/animation",
        modulePath + "/engine/session",
        modulePath + "/engine/command",
        modulePath + "/engine/errorsink",
        modulePath + "/engine/clock",
        modulePath + "/engine/ds",
        modulePath + "/engine/fs",
        modulePath + "/engine/ecs",
        modulePath + "/engine/script",
        modulePath + "/game/menu",
        modulePath + "/game/flow",
        modulePath + "/game/ui",
        modulePath + "/game/defs",
        modulePath + "/game/mode",
        modulePath + "/game/presentation",
        modulePath + "/game/proposals",
        modulePath + "/game/snapshot",
        modulePath + "/game/uidef",
        modulePath + "/game/worldstreaming",
        modulePath + "/render",
        modulePath + "/platform",
    },
    ExactImport: []string{
        modulePath + "/game",
    },
    Rule:        "layers.md: game/interaction/schema may import engine/assets, engine/coretypes, engine/ui/anim, engine/ui/anim/easing, engine/ui/style, failures, and gopher-lua only",
    Remediation: "keep schema as a low-level loader; runtime concerns belong in sibling subpackages added in later chunks",
},
```

`engine/ui/core` is in the forbidden list because chunk 1 has no
need to reference `core.NodeID` or other UINode primitives —
those enter the picture in chunk 3 (rendering). If chunk 2 needs
core types, that chunk's rule update removes them from forbidden.

`engine/ui/style` is **not** in the forbidden list (per D19):
`anim.Animation` color fields are `style.RGBA`, so the chunk 1
animation extractor needs the import. `engine/ui/anim/easing` is
not in the forbidden list either — sub-paths of `engine/ui/anim`
are implicitly allowed since the layer guard is deny-list.

---

## 5. Chunk 2 — Tree Primitives + Mode Roots

**Status:** Draft, pre-implementation.
**Goal:** Stand up `game/interaction/tree/` with `FlowNode`,
`TreeManager`, and the construction/attach/derivation skeleton.
No input, no render, no interpreter, no policies.

### 5.1 Chunk 2 decisions (D23–D35)

#### D23. Package layout — `game/interaction/tree/`

```
game/interaction/tree/
  tree.go        — FlowNode, OpenContext, supporting types
  manager.go     — TreeManager, NewTreeManager, Attach, AttachTo,
                   Detach, UpdateViewStates, Lookup
  errors.go      — op constants
  manager_test.go
  tree_test.go   — light coverage of FlowNode helpers
```

`tree` is a sibling of `schema` under `game/interaction/`. Later
chunks add `render/`, `interpreter/`, `routing/`, etc. as siblings.

#### D24. FlowNode data structure

```go
// FlowNode is the runtime representation of a single authored
// flow node within a TreeManager's tree. It carries identity,
// schema reference, parent/children links, and per-frame derived
// state. Interactive flow_state and exit lifecycle metadata are
// added by chunks 4 and 7 respectively.
type FlowNode struct {
    InstanceID string
    Schema     *schema.Schema   // non-owning pointer into the schema registry
    Parent     *FlowNode        // nil for the mode root
    Children   []*FlowNode      // ordered: persistent first, transient appended

    Persistent bool             // mirrors Schema.Persistent for fast checks
    Visible    bool             // chunk 2: always true; chunks 6+ may toggle

    OpenContext *OpenContext    // nil for persistent nodes; non-nil for transient
    ViewState   *lua.LTable     // refreshed by UpdateViewStates; never nil after first derivation

    // Reserved for chunk 4: FlowState (CurrentStep, Accum, FocusedNodeID).
    // Reserved for chunk 7: Exiting, ExitKeys.
}
```

`OpenContext` is a pass-through carrier for chunk 2:

```go
// OpenContext carries open-time parameters from an Attach call site
// to schema functions via ctx.params (chunk 4+). Chunk 2 stores
// it on the node as-is; chunks 4+ define the schema-function
// projection.
type OpenContext struct {
    Params map[string]any
}
```

`Children` is a single ordered slice. Persistent children are
inserted at construction in declaration order; transient children
append. Render order = slice order (chunks 3 onward).

The `Visible` field is `true` for all nodes in chunk 2. Visibility
toggling (e.g., for input/open suppression) lands in chunk 6.

#### D25. TreeManager construction signature and lifecycle

```go
type TreeManager struct {
    schemas    *schema.Registry
    derivReg   *derivation.Registry
    derivVM    *derivation.DerivationVM
    defsReg    *defs.Registry
    root       *FlowNode
    nodes      map[string]*FlowNode  // by InstanceID
    transientCounter int
}

func NewTreeManager(
    schemas *schema.Registry,
    derivReg *derivation.Registry,
    derivVM *derivation.DerivationVM,
    defsReg *defs.Registry,
    rootSchemaID string,
) (*TreeManager, error)
```

The TreeManager:
- Holds non-owning references to all four registries + the
  DerivationVM. Caller owns lifecycle (`Close` etc.).
- Sets `derivVM.SetWorld(world)` is **not** done here — the caller
  configures the DerivationVM with its World before passing it in,
  matching Phase 5's pattern in `derivation_integration_test.go`.
- Constructs the tree by walking `rootSchemaID`'s
  `persistent_children`. See D26.
- Runs immediate derivation for every node in the constructed
  tree before returning. View_state is non-nil on every node by
  the time `NewTreeManager` returns.

Nil-argument validation: `NewTreeManager` rejects nil dependencies
explicitly with named ops, since this is a public constructor and
silent nil-dereference is a poor failure mode:

- `schemas == nil` → `opTreeNilSchemas`
- `derivReg == nil` → `opTreeNilDerivationRegistry`
- `derivVM == nil` → `opTreeNilDerivationVM`
- `defsReg == nil` → `opTreeNilDefsRegistry`
- `rootSchemaID == ""` → `opTreeEmptyRootSchemaID`

Empty `rootSchemaID` is included because it would produce a
spurious `opTreeUnknownSchema` otherwise; explicit op clarifies
the caller error.

No `Close` method. Resources held by TreeManager are non-owning
references; nothing to release.

#### D26. Mode root assembly

`NewTreeManager`:

1. `schemas.Lookup(rootSchemaID)` — must exist, must be
   `Profile == ProfileRoot`. Else `opTreeNotARootSchema` /
   `opTreeUnknownSchema`.
2. Construct the root `FlowNode` with `InstanceID = rootSchemaID`
   (mode root's instance_id is its schema_id; D6).
3. Walk `Schema.Root.PersistentChildren`, instantiating each as
   a child `FlowNode`:
   - Look up the child schema in `schemas`. Missing →
     `opTreeUnresolvedChild` (per D27).
   - **Reject root-profile children:** if the child schema's
     `Profile == ProfileRoot`, fail with `opTreeRootAsChild`. Mode
     roots are roots of trees, not children. This catches authoring
     errors at construction time rather than letting them propagate
     into nonsensical trees.
   - Compute the child's `InstanceID` per D6:
     `<parent_instance_id>.<child_schema>`. If multiple siblings
     share the same schema_id, append `.<index>` where index is
     the child's position among same-schema siblings (zero-based,
     stable from authoring order in `persistent_children`).
   - No recursion. Only root-profile schemas have a
     `persistent_children` field, and they are forbidden as
     children. The tree depth from a mode root via persistent
     children is therefore exactly 1.
4. Aggregate any per-node failures (unresolved children,
   root-as-child, construction errors) and return them via
   `errors.Join` or a single failure if only one occurred.
5. Run `UpdateViewStates` to populate every node's view_state.
   Errors during initial derivation propagate; the TreeManager is
   not returned in a half-constructed state.

#### D27. Cross-schema reference resolution at tree construction

This is the deferral chunk 1 punted. At `NewTreeManager` time:

- Every entry in `persistent_children` must resolve to a schema in
  the registry. Missing → `opTreeUnresolvedChild` with path
  `persistent_children[<i>]`.
- Every entry in `navigation.from` and `navigation.to` is already
  validated by chunk 1 to be a member of the same schema's own
  `persistent_children` list. Combined with this chunk's
  `persistent_children` resolution, navigation routes are now
  fully resolved (any unresolved navigation target was caught as
  an unresolved persistent child).

Failures at this stage aggregate per-mode-root via
`errors.Join`, similar to chunk 1's per-schema aggregation.

`navigation` resolution beyond schema existence (e.g., that the
target node exists at runtime) is deferred to chunk 5 (focus
routing) — it's a runtime question, not a static one.

#### D28. Attach API

```go
// Attach creates a transient child of the mode root and runs
// immediate derivation. Returns the new node.
func (tm *TreeManager) Attach(schemaID string, octx OpenContext) (*FlowNode, error)

// AttachTo creates a transient child of the named parent and runs
// immediate derivation. Parent must exist and be in the tree.
func (tm *TreeManager) AttachTo(parentInstanceID, schemaID string, octx OpenContext) (*FlowNode, error)
```

Error semantics:
- Schema unknown → `opTreeUnknownSchema`.
- Parent unknown (AttachTo) → `opTreeUnknownParent`.
- Schema is root profile → `opTreeRootCannotAttach` (mode roots
  cannot be transient children).
- Derivation error during immediate derivation → propagated as
  `opTreeDeriveOnAttachFailed` wrapping the underlying error.

On success:
- New node appended to `parent.Children` (transient children
  append; persistent always come first).
- New node registered in `tm.nodes` map.
- View_state populated via immediate derivation.

`OpenContext.Params` is **shallow-copied** on Attach into a fresh
map owned by the new FlowNode. This prevents post-Attach mutation
by the caller from silently changing node state. Nested values
inside the map are not deep-copied at this chunk; chunk 4
introduces a JSON-like deep projection when `ctx.params` reaches
the schema-function VM, which is where deep semantics need to
hold. For chunk 2, the shallow-copy contract is: top-level keys
are stable post-Attach; nested mutation by the caller is a
self-foot-gun the design will close in chunk 4.

#### D29. Detach semantics

```go
// Detach removes a transient child from the tree. Persistent
// children cannot be detached. Returns error on unknown instance
// or persistent target.
func (tm *TreeManager) Detach(instanceID string) error
```

Chunk 2 detach is **synchronous and immediate**:
- Persistent target → `opTreeCannotDetachPersistent`.
- Unknown target → `opTreeUnknownInstance`.
- Mode root target → `opTreeCannotDetachRoot`.
- Otherwise: remove from parent's `Children`, remove from
  `tm.nodes` map, recursively detach all descendants (transient
  or otherwise) from the map.

**No exit animations, no animator-side cleanup, no exit_keys
polling.** The exit lifecycle (mark exiting, freeze view_state,
play exit animations, reap when keys complete) lands in chunk 7.
Chunk 2's Detach is a pure synchronous mutation of the tree
structure.

#### D30. Transient ID format and counter

Per D6, transient instance IDs are runtime-generated. Chunk 2
locks the format:

```
<parent_instance_id>.<schema_id>:<counter>
```

Examples (counter shown in parentheses for clarity):
- `battle_root.pause_menu:0` (first Attach, counter→1)
- `battle_root.pause_menu:1` (second Attach, counter→2)
- `battle_root.pause_menu:1.notification:2` (third Attach,
  AttachTo'd under the second pause_menu, counter→3 — the suffix
  is the **global** counter value, not a per-parent index)

The counter is a single monotonic `int` on the TreeManager,
incremented on each Attach/AttachTo. It does not reset on Detach
— freed counter values are not reused. The counter resets only
when a new TreeManager is constructed (i.e., per mode start).
Suffix values are therefore unique across the tree and across
sibling/parent relationships.

Rationale: monotonic-without-reuse keeps stale IDs in animator
state from accidentally aliasing fresh nodes.

#### D31. Immediate derivation on attach

Per design spec §3.2 / §7.3, attach must immediately populate
view_state for the new node before returning to the caller.

`Attach` / `AttachTo`:
1. Construct the FlowNode with `ViewState = nil` placeholder.
2. Add to tree.
3. Call derivation for the new node (and any persistent children
   it brings — though as noted in D26, non-root profiles don't
   have persistent_children, so this is typically just the new
   node).
4. Store result on `node.ViewState`.
5. If derivation fails: roll back the tree mutation (remove
   from parent + map) and return the wrapped error.

The "immediate" guarantee: the returned `*FlowNode` has a non-nil
`ViewState` populated from a derivation run that happened during
the Attach call.

#### D32. UpdateViewStates traversal

```go
// UpdateViewStates walks all visible nodes depth-first in
// child-order and refreshes view_state via derivation. Stops at
// the first error and returns it; nodes after the failure point
// retain their prior view_state.
func (tm *TreeManager) UpdateViewStates() error
```

Traversal:
- Depth-first.
- Within each parent, child order = slice order (persistent first,
  transient appended).
- Skips nodes where `Visible == false` (chunk 2 always visible;
  chunks 6+ may set false).
- Per-node: call derivation (per D33 optionality) and store on
  `node.ViewState`.

Error policy: stop at first error. Per-frame UpdateViewStates is
called from HandleFrame (chunk 6); a derivation error is a real
authoring/runtime bug that should propagate to the host. Chunk 7
revisits this if the exit lifecycle wants frozen view_state to
survive transient derivation failures.

#### D33. Derivation optionality — missing module → empty view_state

A schema_id without a corresponding derivation module is **not an
error**. The TreeManager:
- Asks the derivation registry whether a module exists for the
  schema (via D34's `HasModule`).
- If yes: calls `EvaluateDerivation(vm, schemaID, defsReg)` per
  Phase 5 and stores the returned `*lua.LTable`.
- If no: stores a freshly-allocated empty `*lua.LTable`
  (`derivVM.NewTable()` or via the runtime VM the manager
  controls) on the node. View_state is never nil after derivation
  runs.

Rationale: schemas like `command_panel` (passive stub in chunk 2)
or mode roots (battle_root, title_root, overworld_root) don't
need ECS or defs to render. Forcing them to author trivial
`Derive(ecs, defs) return {} end` files is friction. Optional
derivation mirrors the optional-`on_complete`/`on_cancel` pattern
from chunk 1.

#### D34. Phase 5 surface extensions — additive accessors

Chunk 2 adds **two** tiny additive methods to Phase 5 packages.
Both are read-only / allocation-only and change no existing
behavior. Per D10 (amended), this is permitted.

**Addition 1 — `derivation.Registry.HasModule`:**

```go
// HasModule reports whether a derivation module is registered for
// the given schema_id.
func (r *Registry) HasModule(schemaID string) bool {
    _, ok := r.modules[schemaID]
    return ok
}
```

Rationale: lets the tree manager check derivation existence
without calling `EvaluateDerivation` and parsing a specific
error-code for "module not found." See D33.

**Addition 2 — `derivation.DerivationVM.NewTable`:**

```go
// NewTable allocates a fresh empty *lua.LTable on the
// DerivationVM's runtime LState. Used by callers that need to
// produce empty view_state tables (e.g., the tree manager when a
// schema has no derivation module).
//
// Behavior on a closed VM (after Close) is undefined and matches
// the rest of the DerivationVM contract from Phase 5 chunk 2 —
// the caller must not invoke methods on a closed VM.
func (v *DerivationVM) NewTable() *lua.LTable {
    return v.lstate.NewTable()
}
```

Rationale: the tree manager needs an empty `*lua.LTable` for
schemas without a derivation module (D33). The empty table must
live on the same LState as future derivation results so view_state
consumers (chunk 3+) see consistent ownership. Adding `NewTable`
is preferable to either (a) error-code-matching against
`EvaluateDerivation` failures, or (b) the tree manager owning its
own auxiliary LState just for empty tables.

Closed-VM behavior: same as Phase 5's existing methods. Calling
`NewTable` after `Close()` is undefined; the implementer is not
required to defensive-check.

These are the **only** Phase 5 surface changes in chunk 2.
`EvaluateDerivation`, `Load`, `AssembleDefsTable`, etc. are
unchanged.

#### D35. Stub schemas — `command_panel.lua`, `title_menu.lua`, `enemy_status.lua`, `turn_order.lua`

Chunk 2 authors four passive-profile stubs to make
`battle_root.persistent_children = ["party_status", "enemy_status", "turn_order", "command_panel"]`
and `title_root.persistent_children = ["title_menu"]` resolve at
tree construction (D27). `party_status` already exists from chunk
1; the other four are the new stubs.

```lua
-- game/assets/scripts/game/interaction/schemas/command_panel.lua
return {
    id = "command_panel",
    persistent = true,
    interactive = false,
    focus_entry = "none",
}
```

```lua
-- game/assets/scripts/game/interaction/schemas/title_menu.lua
return {
    id = "title_menu",
    persistent = true,
    interactive = false,
    focus_entry = "none",
}
```

```lua
-- game/assets/scripts/game/interaction/schemas/enemy_status.lua
return {
    id = "enemy_status",
    persistent = true,
    interactive = false,
    focus_entry = "none",
}
```

```lua
-- game/assets/scripts/game/interaction/schemas/turn_order.lua
return {
    id = "turn_order",
    persistent = true,
    interactive = false,
    focus_entry = "none",
}
```

All four are passive with no derivation files. They satisfy chunk
2's cross-schema resolution requirement. **Chunk 4** upgrades
`command_panel` and `title_menu` to interactive profile when the
interpreter lands; `enemy_status` and `turn_order` remain passive
stubs until later chunks give them real content. The schema files
are revised in those chunks, not chunk 2.

The profile changes are intentional. Chunk 2's purpose is the tree
mechanism, not authored interactive content — passive stubs let
the mechanism land without requiring chunk 4's interpreter to be
in place.

### 5.2 Chunk 2 acceptance

#### 5.2.1 New files

```
game/interaction/tree/
  tree.go           — FlowNode, OpenContext, types
  manager.go        — TreeManager, NewTreeManager, Attach, AttachTo,
                      Detach, UpdateViewStates, Lookup
  errors.go         — op constants
  manager_test.go
  tree_test.go

game/assets/scripts/game/interaction/schemas/
  command_panel.lua
  title_menu.lua
  enemy_status.lua
  turn_order.lua
```

#### 5.2.2 Modified files

- `game/ui/derivation/registry.go` — add `HasModule(schemaID) bool`
  (one method, no behavior change to existing code). See D34.
- `game/ui/derivation/vm.go` — add `(*DerivationVM) NewTable() *lua.LTable`
  (one method, no behavior change to existing code). See D34.
- `internal/importguard/import_guard_test.go` — add
  `game/interaction/tree` rule.
- `game/assets/catalog.json` — regenerated by `tools/cataloggen/`.

#### 5.2.3 Test matrix

Tests split into two layers:

- **Unit tests** in `game/interaction/tree/` use **synthetic test
  schemas** authored as in-memory test fixtures (mirroring the
  chunk 1 fake-script-reader pattern). They do **not** import
  `afterimage/game` (forbidden by layer guard) and do **not**
  use real ECS components like `Fighter`. They cover tree
  mechanics, ID format, counter behavior, error cases.
- **Integration test** in `game/tree_integration_test.go` (package
  `game`) loads the real chunk 1 + chunk 2 authored schemas, sets
  up an ECS world with `Fighter` registered, and exercises
  battle_root end-to-end with real derivation. This is the only
  test that imports game-level types.

`game/interaction/tree/manager_test.go` (unit, synthetic fixtures):

Construction:
- `TestNewTreeManager_EmptyChildren` — synthetic root with no
  persistent_children works; root has no children.
- `TestNewTreeManager_OneChild` — synthetic root with one passive
  child resolves; instance_ids deterministic.
- `TestNewTreeManager_PersistentIDs_NoSiblings` — single child of
  schema X gets InstanceID `<root>.X` (no suffix).
- `TestNewTreeManager_DuplicateChildSchema_IndexSuffix` — synthetic
  root with two children of same schema_id; both children carry
  `.0` / `.1` suffixes (suffix applied to **all** when ≥2 share
  per D26).
- `TestNewTreeManager_UnknownRootSchema` →
  `opTreeUnknownSchema`.
- `TestNewTreeManager_NonRootSchema` (passive schema_id passed
  as root) → `opTreeNotARootSchema`.
- `TestNewTreeManager_UnresolvedChild` → `opTreeUnresolvedChild`;
  path includes `persistent_children[<i>]`.
- `TestNewTreeManager_RootProfileChild_Rejected` (synthetic root
  whose persistent_children references another root-profile
  schema) → `opTreeRootAsChild`.
- `TestNewTreeManager_AggregatesMultipleResolutionFailures` —
  synthetic root with two missing children; both surface in
  joined error.
- `TestNewTreeManager_NilSchemas` → `opTreeNilSchemas`.
- `TestNewTreeManager_NilDerivationRegistry` →
  `opTreeNilDerivationRegistry`.
- `TestNewTreeManager_NilDerivationVM` → `opTreeNilDerivationVM`.
- `TestNewTreeManager_NilDefsRegistry` → `opTreeNilDefsRegistry`.
- `TestNewTreeManager_EmptyRootSchemaID` →
  `opTreeEmptyRootSchemaID`.

Derivation:
- `TestUpdateViewStates_NoDerivation_EmptyTable` — synthetic
  passive child with no derivation module; view_state is a
  non-nil empty `*lua.LTable`.
- `TestUpdateViewStates_WithSyntheticDerivation_Populates` —
  synthetic schema + synthetic derivation that returns
  `{ flag = true }`; view_state has the field.
- `TestUpdateViewStates_DerivationError_StopsAndPropagates` —
  synthetic derivation that raises a Lua error; UpdateViewStates
  fails with wrapped error.
- `TestNewTreeManager_RunsInitialDerivation` — view_state non-nil
  on every node immediately after `NewTreeManager` returns.

Attach/AttachTo:
- `TestAttach_DefaultParent` — Attach succeeds; node has
  instance_id `<root>.<schema>:0`; view_state populated.
- `TestAttach_RunsImmediateDerivation` — view_state non-nil before
  Attach returns.
- `TestAttach_TransientCounterIncrements` — multiple Attach calls
  produce `:0`, `:1`, `:2` (global monotonic).
- `TestAttach_TransientCounterDoesNotResetOnDetach` — Attach,
  Detach, Attach again; second Attach gets `:1`, not `:0`.
- `TestAttach_TransientCounterIsGlobal` — AttachTo a transient
  under another transient; suffix is the global counter value
  (not a per-parent index). Asserts ID like
  `<root>.A:0.B:1`.
- `TestAttach_UnknownSchema` → `opTreeUnknownSchema`.
- `TestAttach_RootSchemaRejected` → `opTreeRootCannotAttach`.
- `TestAttachTo_ExplicitParent` — AttachTo with explicit
  parent ID succeeds.
- `TestAttachTo_UnknownParent` → `opTreeUnknownParent`.
- `TestAttach_DerivationError_RollsBack` — derivation fails on
  attach; node not added to tree or map; error propagates.
- `TestAttach_ParamsCopiedShallow` — caller mutates `Params`
  after Attach; node's stored params are unchanged.

Detach:
- `TestDetach_Transient_Removes` — Detach removes node and any
  descendants from tree + map.
- `TestDetach_Persistent_Rejected` →
  `opTreeCannotDetachPersistent`.
- `TestDetach_Root_Rejected` → `opTreeCannotDetachRoot`.
- `TestDetach_UnknownInstance` → `opTreeUnknownInstance`.
- `TestDetach_OfTransientWithChildren_RemovesAll` — transient
  with attached transient grandchildren; Detach removes the whole
  subtree.

Lookup:
- `TestLookup_Hit` and `TestLookup_Miss`.

`game/interaction/tree/tree_test.go`:
- Light coverage of any helpers added on FlowNode / OpenContext.
  If FlowNode has no helpers, this file is omitted; surface that
  in the chat-level summary.

#### 5.2.4 Integration proof

`game/tree_integration_test.go` (package `game`) — `TestTreeManager_BattleRoot_EndToEnd`:

1. Read chunk 1 + chunk 2 authored schemas from the asset catalog.
2. Load schema, defs, derivation registries (Phase 5 plumbing —
   reused from chunk 1 / Phase 5 tests). **Components registry
   not loaded** — chunk 2 has no rendering yet, so there's nothing
   for components to do. (The integration test is in package
   `game`, which can import `game/ui/components`; the constraint
   is purely "no rendering in chunk 2," not a layer-guard one.
   Chunk 3 adds the components registry to this same test.)
3. Set up an ECS world with `Fighter` registered (via
   `RegisterComponents` from package `game`); spawn 3 fighters
   like the Phase 5 derivation integration test.
4. Construct `DerivationVM` and call `SetWorld(world)`.
5. `NewTreeManager(schemas, derivReg, derivVM, defsReg, "battle_root")`.
6. Walk `tm.root.Children` — assert four persistent children in
   declaration order (party_status, enemy_status, turn_order,
   command_panel):
   - `battle_root.party_status` — view_state has `fighters` table
     with 3 entries; one's `is_low_hp` is true.
   - `battle_root.enemy_status` — view_state is empty table
     (no derivation, per D33; passive stub per D35).
   - `battle_root.turn_order` — view_state is empty table
     (no derivation, per D33; passive stub per D35).
   - `battle_root.command_panel` — view_state is empty table
     (no derivation, per D33; passive stub per D35).
7. `Attach("pause_menu", OpenContext{})` — assert new InstanceID is
   `battle_root.pause_menu:0`; assert pause_menu view_state is
   empty table (no derivation).
8. `Detach("battle_root.pause_menu:0")` — tree returns to its 4
   persistent children.
9. `tm.Lookup("battle_root.party_status")` returns the persistent
   party_status node.

This is the only chunk 2 test that imports game-level types
(`Fighter`, `RegisterComponents`). Its location in package `game`
follows the Phase 5 derivation integration test precedent.

#### 5.2.5 Validation gate

```
go run ./tools/cataloggen/ -check
go test ./game/interaction/...
go test -tags guard ./internal/importguard/...
go run ./tools/devctl testall
```

All four must pass.

### 5.3 Chunk 2 implementation notes

#### 5.3.1 Tree construction algorithm

```
NewTreeManager(schemas, derivReg, derivVM, defsReg, rootSchemaID):
    if schemas == nil: return opTreeNilSchemas
    if derivReg == nil: return opTreeNilDerivationRegistry
    if derivVM == nil: return opTreeNilDerivationVM
    if defsReg == nil: return opTreeNilDefsRegistry
    if rootSchemaID == "": return opTreeEmptyRootSchemaID

    rootSchema = schemas.Lookup(rootSchemaID)
    if rootSchema == nil: return opTreeUnknownSchema
    if rootSchema.Profile != ProfileRoot: return opTreeNotARootSchema

    tm = &TreeManager{...}
    rootNode = &FlowNode{InstanceID: rootSchemaID, Schema: rootSchema,
                         Parent: nil, Persistent: true, Visible: true}
    tm.nodes[rootSchemaID] = rootNode
    tm.root = rootNode

    var errs []error
    for i, childSchemaID := range rootSchema.Root.PersistentChildren:
        childSchema = schemas.Lookup(childSchemaID)
        if childSchema == nil:
            errs = append(errs, opTreeUnresolvedChild for path persistent_children[i])
            continue
        if childSchema.Profile == ProfileRoot:
            errs = append(errs, opTreeRootAsChild for path persistent_children[i])
            continue
        childInstanceID = computeInstanceID(rootNode, childSchema, sameSchemaIndex(...))
        childNode = &FlowNode{InstanceID: childInstanceID, Schema: childSchema,
                              Parent: rootNode, Persistent: true, Visible: true}
        tm.nodes[childInstanceID] = childNode
        rootNode.Children = append(rootNode.Children, childNode)

    if len(errs) > 0: return errors.Join(errs...)

    if err := tm.UpdateViewStates(); err != nil: return err
    return tm
```

The pseudocode is illustrative. There is **no recursion**: only
mode-root schemas have `persistent_children`, and root-profile
schemas are rejected as children, so persistent depth from the
mode root is exactly 1. Transient depth is unbounded but is
extended only via `Attach`/`AttachTo`, not at construction.

#### 5.3.2 Persistent ID computation

Per D6 + D26:

```
computeInstanceID(parent, childSchema, siblingIndex):
    if parent == nil:
        return childSchema.ID  // mode root
    base = parent.InstanceID + "." + childSchema.ID
    if siblingIndex > 0 OR sameSchemaSiblings(parent, childSchema) > 1:
        return base + "." + str(siblingIndex)
    return base
```

`sameSchemaSiblings` counts how many entries in
`parent.Schema.Root.PersistentChildren` equal `childSchema.ID`.
If exactly one, no suffix. If multiple, suffix `.<i>` where `i` is
the position among same-schema siblings (zero-based).

The decision to suffix even the first instance when there are
multiples is to keep the indexing consistent — readers shouldn't
have to look up "is there more than one of this schema?" to
predict an ID.

#### 5.3.3 Derivation invocation

```go
func (tm *TreeManager) deriveNode(node *FlowNode) error {
    schemaID := node.Schema.ID
    if !tm.derivReg.HasModule(schemaID) {
        node.ViewState = tm.derivVM.NewTable()  // empty table on the runtime VM
        return nil
    }
    vs, err := tm.derivReg.EvaluateDerivation(tm.derivVM, schemaID, tm.defsReg)
    if err != nil {
        return failures.Wrap(failures.Reject, opTreeDeriveFailed, err)
    }
    node.ViewState = vs
    return nil
}
```

`HasModule` and `NewTable` are both specified in D34; the
implementer adds them to `game/ui/derivation/registry.go` and
`game/ui/derivation/vm.go` respectively. The empty-table contract
is: every FlowNode has a non-nil `ViewState` after `deriveNode`
returns successfully, regardless of whether a derivation module
exists for the schema. **No stub derivation files are authored in
chunk 2** — schemas without a derivation module simply use the
empty-table path.

#### 5.3.4 OpenContext shallow-copy on attach

Per D28, `Attach` and `AttachTo` allocate a fresh
`map[string]any` and copy the caller's `Params` entries into it
before storing on the new FlowNode:

```go
func (tm *TreeManager) Attach(schemaID string, octx OpenContext) (*FlowNode, error) {
    // ... resolve schema, parent, compute InstanceID ...
    paramsCopy := make(map[string]any, len(octx.Params))
    for k, v := range octx.Params {
        paramsCopy[k] = v
    }
    node.OpenContext = &OpenContext{Params: paramsCopy}
    // ... append to parent, register in map, run derivation ...
}
```

This is a **top-level shallow copy**: the outer map is
caller-isolated, but nested values (slices, maps, structs inside
`any`) are not deep-copied. Chunk 4 introduces the JSON-like
deep projection when `ctx.params` reaches the interpreter VM,
which is where deep semantics need to hold for schema function
isolation. For chunk 2, top-level isolation is sufficient to
prevent the most common foot-gun (caller mutates the params map
after Attach, expecting the node to see updates or expecting it
not to).

If `octx.Params == nil`, store an empty (non-nil) map on the
node. This keeps post-Attach reads simple — no nil checks at
chunk 4's projection site.

#### 5.3.5 Layer guard rule

```go
{
    ImporterBase: modulePath + "/game/interaction/tree",
    ForbiddenBase: []string{
        modulePath + "/engine/runtime",
        modulePath + "/engine/pipeline",
        modulePath + "/engine/ui/core",     // chunk 3 wires UINode types
        modulePath + "/engine/ui/query",
        modulePath + "/engine/ui/style",
        modulePath + "/engine/ui/anim",     // chunk 7 wires animator
        modulePath + "/engine/sim",
        modulePath + "/engine/world",
        modulePath + "/engine/presentation",
        modulePath + "/engine/overlay",
        modulePath + "/engine/text",
        modulePath + "/engine/input",
        modulePath + "/engine/display",
        modulePath + "/engine/animation",
        modulePath + "/engine/session",
        modulePath + "/engine/command",
        modulePath + "/engine/errorsink",
        modulePath + "/engine/clock",
        modulePath + "/engine/ds",
        modulePath + "/engine/fs",
        modulePath + "/game/menu",
        modulePath + "/game/flow",
        modulePath + "/game/ui/components",
        modulePath + "/game/ui/bridge",
        modulePath + "/game/ui/host",
        modulePath + "/game/mode",
        modulePath + "/game/presentation",
        modulePath + "/game/proposals",
        modulePath + "/game/snapshot",
        modulePath + "/game/uidef",
        modulePath + "/game/worldstreaming",
        modulePath + "/render",
        modulePath + "/platform",
    },
    ExactImport: []string{
        modulePath + "/game",
    },
    Rule:        "layers.md: game/interaction/tree may import engine/assets, engine/coretypes, engine/ecs, engine/script, game/defs, game/interaction/schema, game/ui/derivation, failures, and gopher-lua only",
    Remediation: "keep tree as a runtime-only structural module; UINode/animation/render concerns belong in sibling subpackages added in later chunks",
},
```

`engine/ui/anim` is in the **forbidden** list for chunk 2 because
chunk 2 does not interact with the animator. Chunk 7 will relax
this when schema-level enter/exit lifecycle lands. Until then,
animation declarations stored on `Schema.EnterAnimations` /
`Schema.ExitAnimations` (extracted in chunk 1) sit in the schema
package; the tree package never reads them.

`game/ui/components` and `game/ui/bridge` are forbidden for
chunk 2 (rendering belongs to chunk 3).

`engine/ecs` is allowed because the DerivationVM internally needs
World access for the `engine.query` bridge; the tree package
doesn't directly call ECS APIs but transitively imports the
package by virtue of holding a `*derivation.DerivationVM`.

---

## 6. Chunk 3 — Render Path + Immediate-Mode Rebuild

**Status:** Draft, pre-implementation.
**Goal:** Land `TreeManager.Render` mechanics — component
evaluation, animator reconciliation, ScreenUI/WorldUI
composition. Lock the anchor enum and the
component-id-to-schema-id convention. Live HandleFrame
integration and the cutover switch are **deferred** to a later
chunk per D45 (the engine/pipeline animator surface required for
HandleFrame to drive Render does not yet exist).

### 6.1 Chunk 3 decisions (D36–D46)

#### D36. Layer guard relaxation for `game/interaction/tree`

Chunk 3 expands the `game/interaction/tree` import guard to
permit the rendering dependencies. The forbidden list loses:

- `engine/ui/core` — TreeManager builds `core.UINode` results
- `engine/ui/anim` — Render calls `animator.Reconcile`
- `engine/ui/style` — Render takes a `style.StyleContext`
- `game/ui/components` — TreeManager calls
  `components.EvaluateComponent`
- `game/ui/bridge` — referenced via `bridge.TranslateScope`
  passed into `EvaluateComponent`

The forbidden list still includes `game/menu`, `game/flow`,
`engine/runtime`, `engine/pipeline`, `engine/sim/*`, etc. The
package remains a runtime-only orchestrator; rendering deps are
permitted because TreeManager owns rendering per design spec §14.

Updated rule sentence: *"layers.md: game/interaction/tree may
import engine/assets, engine/coretypes, engine/ecs, engine/script,
engine/ui/core, engine/ui/anim, engine/ui/style, game/defs,
game/interaction/schema, game/ui/components, game/ui/bridge,
game/ui/derivation, failures, and gopher-lua only"*.

#### D37. `NewTreeManager` Config struct refactor

Chunk 2 takes 5 positional params; chunk 3 needs more
(adding `*components.Registry` + a component LState).
Chunk 3 introduces a `Config` struct:

```go
type Config struct {
    Schemas  *schema.Registry
    DerivReg *derivation.Registry
    DerivVM  *derivation.DerivationVM
    DefsReg  *defs.Registry
    CompReg  *components.Registry
    CompVM   *lua.LState  // returned from components.NewComponentVM(scripts)
}

func NewTreeManager(cfg Config, rootSchemaID string) (*TreeManager, error)
```

`components.NewComponentVM` returns a `*lua.LState` directly
(no wrapper type — verified in `game/ui/components/component_vm.go`).
The `CompVM` field is therefore `*lua.LState`, not a hypothetical
`*components.ComponentVM`. Caller is responsible for `Close`-ing
the LState; TreeManager does not own its lifecycle.

**No `ModeName` field.** The mode-name string used by `Render`
for `TranslateScope.Scope` and synthetic root UINode IDs is
**deterministically derived** as `rootSchemaID` — the same string
the caller passes to `NewTreeManager`. TreeManager stores
`tm.rootSchemaID` and uses it directly wherever an earlier draft
would have used `cfg.ModeName`. Caller-controlled divergence
between mode name and `rootSchemaID` is rejected by construction
(the field doesn't exist), eliminating fragility where the same
flow tree could produce different UINode IDs depending on caller
choice. See D41.

Nil-argument checks (D25) extend to all six pointer fields and
empty `rootSchemaID`. New op constants:
`opTreeNilComponentRegistry`, `opTreeNilComponentVM`. Existing
chunk 2 nil ops (`opTreeNilSchemas`, etc.) remain. There is no
`opTreeEmptyModeName` — empty `rootSchemaID` is already covered
by `opTreeEmptyRootSchemaID` from chunk 2.

**Chunk 2 retrofit and test helper:** Chunk 2's `manager_test.go`
and `tree_integration_test.go` use the positional 5-param
`NewTreeManager` signature; chunk 3 changes that signature.
Chunk 3 also lands a shared test helper:

```go
// game/interaction/tree/testhelpers_test.go (new in chunk 3)
//
// newTestConfig builds a Config with all six dependencies populated
// from the supplied registries and supplied component LState.
// Lifecycle ownership of compVM stays with the caller; the helper
// just packs the values into a Config. Used by manager_test.go and
// render_test.go so each test doesn't reconstruct the boilerplate.
func newTestConfig(t *testing.T, schemas *schema.Registry,
    derivReg *derivation.Registry, derivVM *derivation.DerivationVM,
    defsReg *defs.Registry, compReg *components.Registry,
    compVM *lua.LState) Config { ... }
```

Chunk 2's existing tests gain a real (test-fixture-driven)
`*components.Registry` and `*lua.LState` even though they don't
exercise rendering. The tree mechanics they verify don't depend
on rendering, but the constructor's nil-arg discipline requires
non-nil deps. The helper centralizes the boilerplate so the
retrofit is one-touch per existing test. Chunk 3's render tests
use the same helper.

This means chunk 2's unit tests (chunk-2-only behavior) now
construct a real components Registry from in-memory fixtures
even though they never call `Render`. The cost is modest because
chunk 1 already established the in-memory-fixture pattern for
schemas and chunk 2 extends it to derivations; adding an empty
components Registry follows the same pattern.

#### D38. `TreeManager.Render` API

```go
// Render walks visible nodes depth-first in child-order, evaluates
// each node's component, calls animator.Reconcile for declared
// animations, and composes the results into ScreenUI / WorldUI
// roots. Render does NOT call UpdateViewStates internally — the
// caller (HandleFrame) ensures view_states are fresh before
// invoking Render.
//
// Render's only animator mutation is Reconcile (per node, for
// component-declared animations). Render does NOT call Advance,
// Apply, or Sweep — those are caller's responsibility (engine
// runtime today; HandleFrame in design spec §12.2).
//
// Render is not threadsafe; the caller serializes per frame.
func (tm *TreeManager) Render(animator *anim.Animator, style style.StyleContext) (screenUI, worldUI core.UINode, err error)
```

**Returned root IDs (locked):**

```
screenUI.ID = core.NodeID(tm.rootSchemaID)
worldUI.ID  = core.NodeID(tm.rootSchemaID + "/world")
```

`tm.rootSchemaID` is the string the caller passed to
`NewTreeManager`; it is also the mode root's instance_id.
Both root UINodes are `core.NodeKindPanel`. `worldUI` is a bare
panel with no children in chunk 3 (no authored content is
world-anchored).

**Nil-argument handling:**
- `animator == nil` → `opTreeNilAnimator`. Render needs animator
  for Reconcile per node, so a nil animator is a caller contract
  violation, named explicitly to match the constructor's
  nil-arg discipline (D25). `style` is currently a value type
  (`style.StyleContext`); if it ever becomes nil-able, add a
  parallel op then.

**Error handling:** Render stops at first error and returns. Per-node
component evaluation failures (e.g., a component raises a Lua
error) propagate as wrapped errors. Partial state on the animator
(Reconcile calls already made before the failure) is left as-is;
Sweep at end-of-frame will clean up orphans.

#### D39. Reconcile call site within Render

Per D8 (cross-cutting): the animator owns reconciliation logic;
TreeManager.Render owns the call site. The render walk per node:

```go
out, err := components.EvaluateComponent(compVM, schemaID, viewState, flowState, scope)
if err != nil { return failwrap }

for _, na := range out.Animations {
    if err := animator.Reconcile(na.NodeID, na.Declared); err != nil {
        return failwrap
    }
}
```

`out.Animations` is `[]NodeAnimations` (Phase 5 type with `NodeID`
+ `Declared []anim.DeclaredAnimation`). Each entry produces one
`Reconcile` call.

**Schema-level enter/exit animations are NOT reconciled here.**
Per D20, those are imperative one-shots/sequences started at
lifecycle transitions (chunk 7). Render handles only
component-declared, frame-driven animations.

#### D40. Bridge stays Phase 4 internal

`game/ui/components.EvaluateComponent` already invokes
`game/ui/bridge` internally to translate Lua primitive trees into
`core.UINode` trees. Phase 4 owns this translation; chunk 3 does
not reach into the bridge directly.

`TreeManager.Render` calls `EvaluateComponent` and reads the
returned `out.Node` (a `core.UINode`). The bridge's translation
contract is Phase 4's contract; chunk 3 verifies it via the
existing Phase 4 tests, not by re-spec'ing it.

`TreeManager` imports `game/ui/bridge` only to construct the
`bridge.TranslateScope` argument passed to `EvaluateComponent`.
No direct calls into bridge translation.

Bridge purity (D9): unchanged. Translation is deterministic;
animator Reconcile is invoked from Render after the bridge
returns.

#### D41. Component-id-to-schema-id convention

This is the deferral §7 punted from chunk 1. Chunk 3 locks:

> The component for a flow node with schema ID `<id>` is the Lua
> module at asset key `script.game.ui.components.<id>`.

Same flat-name rule as Phase 5 components. `TreeManager.Render`
calls `compReg.HasFlowNodeComponent(<id>)` (D46) to test
existence, then
`compReg.EvaluateComponent(compVM, <id>, viewState, nil, scope)`
when present.

`TranslateScope` shape passed to `EvaluateComponent`:

```go
scope := bridge.TranslateScope{
    Scope:    tm.rootSchemaID,    // e.g., "battle_root", "title_root"
    Instance: node.InstanceID,    // full instance_id including mode-root prefix
}
```

`Scope` is **always `rootSchemaID`** — the same string the caller
passed to `NewTreeManager`, deterministically derived (per D37,
no `ModeName` field). `Instance` is the full instance_id from
chunk 2's identity model. Emitted UINode IDs therefore have the
form `<rootSchemaID>/<instance_id>/<local>` — predictable,
caller-stable, and testable.

**No mode-name override:** chunk 3 deliberately removed the
caller-controlled `ModeName` field that an earlier draft proposed.
With it, two callers passing different `ModeName` values would
produce different UINode IDs for the same flow tree. With
`Scope = rootSchemaID`, the IDs are deterministic and tests can
predict them without knowing caller config.

If a future chunk needs a Scope value distinct from `rootSchemaID`
(for example, if multiple battle modes share a single asset
namespace), that chunk introduces the override deliberately and
locks the divergence rule.

#### D42. Component optionality — missing component → placeholder UINode (non-root nodes only)

**Chunk 3 does NOT render the mode root itself.** The mode root
schema may declare a component (per design spec §8.2), but chunk
3 composes the visible top-level children of the mode root into
synthetic ScreenUI / WorldUI roots — see D43 — without invoking a
component for the mode root. Mode-root component composition
lands in a later chunk (likely chunk 6 or chunk 7) when richer
parent-child layout integration is needed.

For **non-root flow nodes**, component optionality applies:
mirroring chunk 2's derivation optionality (D33), a flow node
whose schema has no corresponding component renders as an empty
placeholder.

```go
// Pseudocode for the per-node render step (non-root nodes only)
if !compReg.HasFlowNodeComponent(schemaID) {
    placeholder := core.UINode{
        ID:   buildPlaceholderID(tm.rootSchemaID, node.InstanceID),
        Kind: core.NodeKindPanel,
        // no children, no styling
    }
    return placeholder, nil  // no animations to reconcile
}
out, err := compReg.EvaluateComponent(...)
// reconcile out.Animations, etc.
```

**Placeholder ID format (locked):**

```
<rootSchemaID>/<instanceID>/placeholder
```

This is the exact format. Tests assert it. The placeholder ID
is stable across frames and unique across nodes (because
`instance_id` is unique within the tree per chunk 2 D6/D30).
Stable IDs preserve the identity bridge (D7) so a flow node
always has a UINode anchor even without a component.

Rationale: Phase 6 schemas are authored incrementally. Chunk 3
needs to render party_status (real component from Phase 5)
alongside command_panel (no component yet). Placeholders let
the proof land without requiring 5+ stub components. Real games
will author per-schema components; chunk 3's placeholder path
is a development convenience.

The "mode root not rendered" rule is consistent with
`UpdateViewStates` (chunk 2 D32) traversing all visible nodes
including the mode root. Render diverges: the mode root
contributes only its visible children to the synthetic
ScreenUI / WorldUI; the mode root's own component (if any) is
not invoked in chunk 3.

#### D43. Composition — ScreenUI / WorldUI by anchor classification

Per design spec §6.2: screen-anchored subtrees go into ScreenUI;
world-anchored subtrees go into WorldUI. Composition rule:

- A flow node's `Schema.Anchor` (chunk 1 extracted) determines
  classification. Per D44, chunk 3 supports only `AnchorCenter`
  (a screen anchor).
- Nodes without an anchor inherit the parent's classification.
  The mode root has no anchor and is screen-classified by
  default. World-anchored mode roots are not authored in chunks
  1-3.
- **Chunk 3 produces ScreenUI populated; WorldUI is a bare
  empty Panel.** No authored content in chunk 1/2 schemas is
  world-anchored. World-anchored composition lands in whatever
  later chunk first authors world-anchored content.

Composition pass:

```
screenRoot := core.UINode{ID: tm.rootSchemaID, Kind: core.NodeKindPanel}
for each visible top-level child of mode root (in child-order):
    if child is screen-classified:
        screenRoot.Children = append(screenRoot.Children, render(child))
worldRoot := core.UINode{ID: tm.rootSchemaID + "/world", Kind: core.NodeKindPanel}
return screenRoot, worldRoot
```

The example pseudocode shows a flat composition with no real
layout. When the mode root has its own component (later chunks),
the mode root's component output dictates child placement.

#### D44. Anchor enum lock — `AnchorCenter` only in chunk 3

Chunk 1 extracted `anchor.kind` as a non-empty string.
Chunk 3 tightens to an enum:

```go
// game/interaction/schema/types.go (modified in chunk 3)
type AnchorKind string

const (
    AnchorCenter AnchorKind = "center"  // viewport center
)

type Anchor struct {
    Kind AnchorKind
}
```

Chunk 1's anchor validator (in `validate.go`) is updated to
enforce the enum. Authored values outside the enum are rejected
with `opSchemaAnchorKindUnknown`.

`pause_menu.lua` (chunk 1 authored) uses `anchor = { kind = "center" }`,
which remains valid. No other chunk-1/2 schema authors anchor.

**Other anchor kinds** (e.g., `screen`, `screen_top`,
`world:<entity_id>`) are added in chunks that author content
needing them. Adding a kind: extend the enum, update the
validator, update D43's classification rules. Each addition is
called out in its landing chunk's D-list.

#### D45. Live HandleFrame integration deferred — engine/pipeline surface change required first

The original chunk 3 carve called for "live HandleFrame
integration behind a cutover switch." That carve is
**infeasible as scoped** because:

- `engine/runtime.FrameRunner` privately owns the
  `*anim.Animator`. It is not exposed via
  `engine/pipeline.FrameContext` (verified — `FrameContext`
  carries Input, Cues, Style, UIQueries, Viewport,
  UIDeltaSeconds, TextInputEvents; no animator field).
- `FrameRunner` already drives `Advance` / `Apply` / `Sweep` on
  the produced UINode trees. Duplicating those calls from
  HandleFrame would double-advance animations.
- `TreeManager.Render` needs animator access to call
  `Reconcile`. Without the animator in `FrameContext`,
  HandleFrame cannot call Render at all.

Two paths exist (per design spec §12.2 vs current Phase 4
runtime), and reconciling them is a non-trivial engine surface
alignment that does not fit chunk 3's scope:

1. Expose the animator via `pipeline.FrameContext` so HandleFrame
   can drive the relevant phases.
2. Move TreeManager.Render invocation into engine/runtime where
   the animator already lives — but TreeManager belongs to game/
   and engine cannot import it.

**Chunk 3 therefore defers live HandleFrame wiring entirely.**
Chunk 3 delivers `TreeManager.Render` mechanics + tests only.
Render takes the animator as a parameter so tests can drive it
directly. The live HandleFrame integration — cutover switch,
engine/pipeline surface change, runtime ownership decisions —
lands in a later chunk (likely chunk 6 when full HandleFrame
integration is the focus, or its own dedicated alignment chunk
inserted between 3 and 4).

Chunk 3 introduces no per-mode flag, no HandleFrame branching,
no engine surface changes. The render mechanics built here are
exercised only by unit and integration tests until the engine
surface is aligned.

The decision to defer was raised by ChatGPT review of an earlier
chunk 3 draft that incorrectly assumed HandleFrame had animator
access; the corrected scope is captured here for traceability.

#### D46. `components.Registry.HasFlowNodeComponent` — additive accessor

Mirroring D34's chunk 2 derivation accessor. Per D10 amended,
additive read-only accessors are permitted when justified.

```go
// game/ui/components/registry.go (added in chunk 3)
//
// HasFlowNodeComponent reports whether a flow-node-level
// component module is registered for the given schema_id.
// (The components Registry tracks two maps: flowNodes and
// stepTypes. Chunk 3 only needs the flow-node lookup.)
func (r *Registry) HasFlowNodeComponent(schemaID string) bool {
    _, ok := r.flowNodes[schemaID]
    return ok
}
```

The actual `Registry` struct has `flowNodes map[string]coretypes.AssetKey`
and `stepTypes map[string]coretypes.AssetKey` (verified in
`game/ui/components/registry.go`). Chunk 3 only needs flow-node
component lookup; step-type component existence is a chunk 4
concern.

Rationale: lets `TreeManager.Render` test for component existence
(D42 placeholder path) without parsing a "module not found" error
from `EvaluateComponent`.

Signature shape and closed-VM behavior follow the same contract
as derivation's `HasModule` (D34). The name diverges from
derivation's because the components Registry has two maps
(`flowNodes` and `stepTypes`); a generic `HasModule` would be
ambiguous.

This is the only Phase 5 surface change in chunk 3.
`EvaluateComponent` is unchanged.

### 6.2 Chunk 3 acceptance

#### 6.2.1 New files

```
game/interaction/tree/
  render.go             — TreeManager.Render method, composition, helpers
  render_test.go        — unit tests for render mechanics
  testhelpers_test.go   — newTestConfig (shared by manager_test.go and
                          render_test.go); see D37
```

No new authored Lua. `command_panel.lua`, `title_menu.lua`,
`battle_root.lua`, etc. continue to lack components per D42.
`party_status.lua` component (Phase 5) remains the only real
component exercised in chunk 3.

#### 6.2.2 Modified files

- `game/interaction/tree/manager.go` — `Config` struct introduced,
  `NewTreeManager` signature changes, new nil-arg ops added.
- `game/interaction/tree/errors.go` — new op constants for
  render errors and the new nil-arg cases.
- `game/interaction/tree/manager_test.go` — chunk 2 tests updated
  to use `Config{...}`.
- `game/tree_integration_test.go` (chunk 2's integration test) —
  updated to use `Config{...}` and to assert Render output (see
  6.2.4).
- `game/ui/components/registry.go` — add
  `HasFlowNodeComponent(schemaID) bool`.
- `game/interaction/schema/types.go` — `AnchorKind` enum.
- `game/interaction/schema/validate.go` — anchor `kind` enum check.
- `game/interaction/schema/validate_test.go` — anchor enum tests.
- `internal/importguard/import_guard_test.go` — relax
  `game/interaction/tree` rule per D36.

No HandleFrame integration files modified. Per D45, that is
deferred to a later chunk.

#### 6.2.3 Test matrix

`render_test.go` (unit, synthetic fixtures in `game/interaction/tree/`):

Render basics:
- `TestRender_EmptyChildren_ReturnsEmptyPanels` — overworld_root
  (no children); ScreenUI is a bare panel with no children;
  WorldUI similarly empty.
- `TestRender_PlaceholderForMissingComponent` — synthetic schema
  with no component module; node renders as a placeholder Panel
  with the expected ID format.
- `TestRender_RealComponentInvocation` — synthetic schema with a
  synthetic component (test fixture); `EvaluateComponent` is
  called with the right scope, view_state, and flowState=nil.
- `TestRender_ChildrenRenderInDeclarationOrder` — synthetic root
  with three children of varying schemas; ScreenUI children appear
  in the same order.
- `TestRender_PassesRootSchemaIDAsScope` — assert the
  `bridge.TranslateScope.Scope` argument passed to
  `EvaluateComponent` equals `tm.rootSchemaID`.
- `TestRender_PassesFullInstanceIDAsInstance` — assert
  `bridge.TranslateScope.Instance` equals the flow node's full
  instance_id (including mode-root prefix).

Animator integration (assert observable animator state, not
call counts — `Animator` is a concrete type with no spy seam):
- `TestRender_AnimatorReflectsDeclaredAnimations` — synthetic
  component returns 2 animation declarations; after Render,
  assert `animator.IsAnimating(<expected_node_id>)` is true for
  each declared NodeID.
- `TestRender_PlaceholderHasNoAnimatorState` — placeholder
  rendering produces no animator state for the placeholder
  NodeID; `animator.IsAnimating(placeholder_id)` is false.
- `TestRender_DoesNotAdvanceOrApply` — call Render once, then
  observe that no animation values were applied to the returned
  UINode tree (e.g., a property that would change after
  `Apply` retains its declared `from` value, not interpolated).
  Apply and Sweep are caller's responsibility.

Composition / classification:
- `TestRender_ScreenAnchorChildIntoScreenUI` — synthetic transient
  with `anchor = { kind = "center" }` Attach'd; rendered subtree
  appears under ScreenUI.
- `TestRender_WorldUIAlwaysEmpty_Chunk3` — assert WorldUI is a
  bare empty panel (no chunk-3 content world-anchored).

Error handling:
- `TestRender_ComponentEvaluationError_Propagates` — synthetic
  component raises a Lua error; Render returns the wrapped
  error.
- `TestRender_NilAnimator_Rejected` → `opTreeNilAnimator`.
- `TestRender_NotThreadsafe_DocumentedViaTestComment` — comment
  in test file noting Render is not threadsafe (no actual
  concurrency test required).

Config refactor:
- `TestNewTreeManager_NilComponentRegistry` →
  `opTreeNilComponentRegistry`.
- `TestNewTreeManager_NilComponentVM` → `opTreeNilComponentVM`.

(No `EmptyModeName` test — `ModeName` removed per D37.
`opTreeEmptyRootSchemaID` from chunk 2 already covers the empty
root case.)

`schema/validate_test.go` additions:
- `TestAnchor_KindCenterAccepted` — `anchor = { kind = "center" }`
  parses to `Anchor{Kind: AnchorCenter}`.
- `TestAnchor_KindUnknownRejected` — `kind = "screen_top"` →
  `opSchemaAnchorKindUnknown`.

`components/registry_test.go` (existing — addition):
- `TestRegistry_HasFlowNodeComponent_Hit` and `_Miss`.

#### 6.2.4 Integration proof

`game/tree_integration_test.go` extended (chunk 2's integration
test grows; same file, new assertions):

`TestTreeManager_BattleRoot_RendersPartyStatus`:

1. Same setup as chunk 2's integration proof: load registries
   (now including components per chunk 3), construct DerivationVM
   + populated ECS world (3 fighters, one with low HP), construct
   `Config`, call `NewTreeManager(cfg, "battle_root")`.
2. Construct an `*anim.Animator` and a `style.StyleContext`
   (test fixtures).
3. Call `tm.UpdateViewStates()` (Phase 5 derivation populates
   party_status's view_state).
4. Call `screenUI, worldUI, err := tm.Render(animator, style)`.
   Assert no error.
5. Walk `screenUI.Children`:
   - One subtree per persistent child of battle_root in
     declaration order: party_status, command_panel.
   - party_status's subtree is the real component output (3
     fighter_<idx> children, each with name + hp_bar nodes).
   - command_panel's subtree is a placeholder Panel.
6. Assert `worldUI` is a bare empty Panel.
7. Assert observable animator state for party_status's declared
   animations. The exact NodeID format with `Scope = rootSchemaID
   = "battle_root"` and `Instance = "battle_root.party_status"` is:

   ```
   battle_root/battle_root.party_status/root/fighter_<idx>/hp_bar
   ```

   where `<idx>` is the ECS entity index for the low-HP fighter
   (f2 in the Phase 5 integration test pattern). This is the
   bridge-translated absolute NodeID; the chunk 3 implementer
   asserts `animator.IsAnimating(thatNodeID)` is true after
   `Render` returns. Note this differs from Phase 5's existing
   `derivation_integration_test.go` IDs (which used `Scope="battle"`,
   `Instance="party_status"`) because chunk 3 lockstep
   `Scope = rootSchemaID` and `Instance = full instance_id` per
   D41. The Phase 5 test is unaffected; chunk 3's integration test
   uses the new format.
8. Call `animator.Apply(&screenUI)` and verify it does not
   error (smoke test for the Apply call site the engine runtime
   already uses).
9. Call `animator.Sweep(screenUI, worldUI)` and verify it does
   not error.

**No HandleFrame switch tests in chunk 3.** Per D45, live
HandleFrame integration is deferred. Chunk 3's integration test
exercises the render mechanics directly via TreeManager; the
HandleFrame branching, per-mode flag, and engine/pipeline animator
surface land in a later chunk.

#### 6.2.5 Validation gate

```
go run ./tools/cataloggen/ -check
go test ./game/interaction/...
go test ./game/ui/components/...
go test ./game/...
go test -tags guard ./internal/importguard/...
go run ./tools/devctl testall
```

All must pass. The broader `./game/...` run (vs. chunks 1-2's
narrower scopes) is to catch regressions from chunk 3's
chunk-2-test retrofit and the schema-package anchor enum check —
both of which touch files outside the chunk 3 package and could
cascade.

### 6.3 Chunk 3 implementation notes

#### 6.3.1 Render walk algorithm

```
Render(animator, style):
    // The mode root itself is NOT rendered in chunk 3 (D42).
    // Synthetic ScreenUI / WorldUI roots aggregate the visible
    // top-level children of tm.root.
    screenRoot = core.UINode{ID: core.NodeID(tm.rootSchemaID), Kind: NodeKindPanel}
    worldRoot  = core.UINode{ID: core.NodeID(tm.rootSchemaID + "/world"), Kind: NodeKindPanel}

    for each visible child of tm.root in child-order:
        subtree, classification, err = renderNode(child, animator, style)
        if err != nil: return zero, zero, err
        switch classification:
        case ScreenAnchored:
            screenRoot.Children = append(screenRoot.Children, subtree)
        case WorldAnchored:
            worldRoot.Children = append(worldRoot.Children, subtree)

    return screenRoot, worldRoot, nil

renderNode(node, animator, style):
    classification = classifyAnchor(node.Schema.Anchor)  // default ScreenAnchored
    if !cfg.CompReg.HasFlowNodeComponent(node.Schema.ID):
        placeholder = core.UINode{
            ID:   buildPlaceholderID(tm.rootSchemaID, node.InstanceID),
            Kind: NodeKindPanel,
        }
        // Recurse into transient children that may themselves render
        for each visible child of node in child-order:
            childSubtree, _, err = renderNode(child, animator, style)
            if err != nil: return zero, zero, err
            placeholder.Children = append(placeholder.Children, childSubtree)
        return placeholder, classification, nil

    scope = bridge.TranslateScope{
        Scope:    tm.rootSchemaID,
        Instance: node.InstanceID,
    }
    out, err = cfg.CompReg.EvaluateComponent(cfg.CompVM, node.Schema.ID,
                                             node.ViewState, /*flowState=*/nil, scope)
    if err != nil: return zero, zero, wrap(err)

    for _, na := range out.Animations:
        if err := animator.Reconcile(na.NodeID, na.Declared); err != nil:
            return zero, zero, wrap(err)

    // For composition, real components own their internal layout.
    // Transient children Attach'd under a node with a real component
    // are rendered as separate subtrees (chunk 3 uses placeholder-
    // style append; chunks 4+ may revisit when interactive
    // composition lands).
    for each visible transient child of node in child-order:
        childSubtree, _, err = renderNode(child, animator, style)
        if err != nil: return zero, zero, err
        out.Node.Children = append(out.Node.Children, childSubtree)

    return out.Node, classification, nil
```

The transient-children-as-appended-subtrees rule is **degraded
composition** for chunk 3. The append happens at the same array
level as the component author's own children, which can interleave
or collide visually with the component's emitted layout. This is
acceptable for chunk 3's proof (pause_menu attaches to battle_root
which has no real component, so no interleaving happens) but is
a known limitation. Chunks 4-7 revisit when interactive flows need
richer parent-child layout integration — likely by introducing a
named transient-overlay slot in the component output, or by having
the bridge place transient children in a separate tree branch.

#### 6.3.2 Anchor classification

```go
type anchorClassification int
const (
    classScreenAnchored anchorClassification = iota
    classWorldAnchored
)

func classifyAnchor(a *schema.Anchor) anchorClassification {
    if a == nil {
        return classScreenAnchored  // default
    }
    switch a.Kind {
    case schema.AnchorCenter:
        return classScreenAnchored
    default:
        return classScreenAnchored  // future kinds added here
    }
}
```

`a == nil` means no anchor authored; default classification is
screen-anchored. This matches design spec §6.2 where the default
is ScreenUI.

#### 6.3.3 Component lookup + placeholder

Per D42:

```go
func (tm *TreeManager) hasComponent(schemaID string) bool {
    return tm.cfg.CompReg.HasFlowNodeComponent(schemaID)
}

func buildPlaceholderID(rootSchemaID, instanceID string) core.NodeID {
    return core.NodeID(rootSchemaID + "/" + instanceID + "/placeholder")
}
```

The `/placeholder` suffix distinguishes placeholder UINodes from
real component-rendered ones (which have whatever local IDs the
component author chose).

#### 6.3.4 Live HandleFrame integration — deferred

Per D45, chunk 3 does not wire HandleFrame. The render mechanics
are exercised through unit tests (`render_test.go`) and the
package-`game` integration test
(`game/tree_integration_test.go`), both of which construct the
TreeManager + animator directly.

The eventual live integration requires `engine/pipeline.FrameContext`
to expose the animator (or for engine/runtime to drive Render
itself, with TreeManager moved or interfaced appropriately).
That alignment is a non-trivial engine surface change and lands
in a later chunk.

#### 6.3.5 Component LState lifecycle

Chunk 3 introduces a component LState reference in the TreeManager
Config (`Config.CompVM *lua.LState`). The implementer:
- Constructs `compVM := components.NewComponentVM(scripts)` at
  module/mode init (returns `*lua.LState`).
- Passes it into `Config.CompVM`.
- Owns the `compVM.Close()` lifecycle (TreeManager does not close
  it — TreeManager has no Close method per D25).

Multiple TreeManagers can share a single component LState if
they share the same script reader. Or each TreeManager can have
its own. Chunk 3 spec doesn't dictate sharing; implementer chooses
based on memory vs isolation tradeoffs.

#### 6.3.6 Layer guard rule update

```go
// internal/importguard/import_guard_test.go — existing rule for
// game/interaction/tree, with these entries REMOVED from
// ForbiddenBase:
//
//    modulePath + "/engine/ui/core",
//    modulePath + "/engine/ui/anim",
//    modulePath + "/engine/ui/style",
//    modulePath + "/game/ui/components",
//    modulePath + "/game/ui/bridge",
//
// Other forbidden entries (engine/runtime, engine/pipeline,
// game/menu, game/flow, etc.) remain.
```

The Rule sentence updates to the D36 wording:

> "layers.md: game/interaction/tree may import engine/assets,
> engine/coretypes, engine/ecs, engine/script, engine/ui/core,
> engine/ui/anim, engine/ui/style, game/defs,
> game/interaction/schema, game/ui/components, game/ui/bridge,
> game/ui/derivation, failures, and gopher-lua only"

---

## 7. Chunk 4 — Interpreter + Interactive Schemas

**Status:** Draft, pre-implementation.
**Goal:** Land the per-flow-node interpreter — `ProcessInput`,
directives, snapshot/rollback, `FlowState`, step type dispatch.
Upgrade `command_panel` and `title_menu` from chunk 2's passive
stubs to interactive profile. Lock the `FlowOutcome` / `FlowResult`
shapes. No HandleFrame wiring (deferred per D11/D45).

### 7.1 Chunk 4 decisions (D47–D63)

#### D47. Cross-cutting D1 amendment — engine/pipeline + game/mode for interpreter

The interpreter package `game/interaction/interpreter/` needs:

- `engine/pipeline.HostRequest` — the canonical host-effect type
  referenced by `FlowOutcome.HostEffects` (design spec §13).
- `game/mode.GameMode` — the canonical mode-transition target
  referenced by `FlowOutcome.Transition` (design spec §13).

Both are **forbidden** for `game/interaction/` by D1's broad
default. Chunk 4 amends D1 (already updated in §3) to permit
these for the interpreter subpackage **only**. Schema and tree
subpackages keep the broad forbid.

This is a deliberate per-subpackage broadening. The alternative —
defining new host-effect/transition types in `game/interaction/`
and adapting at the HandleFrame boundary — adds plumbing without
architectural value. The design spec already names these types
canonical; chunk 4 just allows the import.

#### D48. Package layout — `game/interaction/interpreter/`

```
game/interaction/interpreter/
  interpreter.go    — Interpreter type, New, Close
  open.go           — OpenFlow, FlowState initialization
  process.go        — ProcessInput, step dispatch, directive parsing
  outcome.go        — FlowOutcome, FlowResult Go types
  ctx.go            — ctx.view / ctx.params / accum projection
  snapshot.go       — Accum snapshot/rollback
  payload.go        — Lua → JSON marshaller for command payloads
  errors.go         — op constants
  interpreter_test.go
  open_test.go
  process_test.go
  outcome_test.go
  payload_test.go
  testhelpers_test.go — newTestInterpreter shared helper
```

Sibling of `schema/`, `tree/` under `game/interaction/`. Imports:
`game/interaction/schema`, `game/interaction/tree`, `game/defs`
(for `defs.DeepCopyLuaTable`, see D62 / §7.3.2); plus
`engine/pipeline`, `game/mode`, `engine/command` per D47. The
interpreter does **not** import `game/ui/derivation` —
view_state cross-VM copy uses `defs.DeepCopyLuaTable` directly,
not a derivation registry method.

#### D49. Interpreter type and lifecycle

```go
// game/interaction/interpreter/interpreter.go

type Interpreter struct {
    vm      *lua.LState  // private; sandboxed schema-function VM
    scripts assets.ScriptReader
    schemas *schema.Registry  // for step lookup + back validation
    // closed flag for idempotent Close
}

func New(scripts assets.ScriptReader, schemas *schema.Registry) (*Interpreter, error)
func (ip *Interpreter) Close()  // idempotent; releases vm
```

`New`:
- Builds the interpreter VM (D50 profile).
- Calls `script.InstallRequire(vm, scripts)` so schemas can be
  loaded by asset key.
- Validates inputs non-nil; returns `opInterpNilScripts` /
  `opInterpNilSchemas` on nil args.
- Does NOT preload schemas; each schema module is `require`d
  on first `OpenFlow` for that schema, then cached by Lua's
  require system.

`Close`:
- Releases the LState. Idempotent (mirroring chunk 1's `Close`
  pattern).
- After `Close`, all subsequent `OpenFlow` / `ProcessInput` calls
  return `opInterpClosed`.

The Interpreter is owned by the host (game module / mode runtime).
Single instance per mode (typical) or per TreeManager (alternative).
Chunk 4 spec doesn't dictate; chunk 6 HandleFrame integration
locks the lifecycle.

#### D50. Interpreter VM profile

Sandbox profile:

```go
L := lua.NewState(lua.Options{SkipOpenLibs: true})
lua.OpenBase(L)    // require, basic globals
lua.OpenTable(L)   // table.insert, table.concat, etc.
lua.OpenString(L)  // string.format, string.match, etc.
lua.OpenMath(L)    // math.floor, math.max, etc.
script.DisableMathRandom(L)            // no nondeterminism
script.DisableDangerousGlobals(L)      // no os, io, debug, etc.
script.InstallRequire(L, scripts)
```

Same sandbox helpers Phase 5 chunk 1 exported. Schema functions
run with stdlib subset; engine bridges (engine.query, engine.fixed)
are NOT installed because the interpreter only consumes view_state
+ params + accum, not raw ECS or fixed-point math.

Distinct from:
- DerivationVM (has engine.query, engine.fixed, no stdlib stripped)
- ComponentVM (no stdlib at all)
- Schema loader VM (no libs, ephemeral)

#### D51. FlowState data structure

```go
// game/interaction/tree/tree.go (modified in chunk 4)
//
// Reserved-for-chunk-4 field on FlowNode now populated.
type FlowState struct {
    CurrentStep   string       // step ID; matches schema.Steps key
    Accum         *lua.LTable  // lives on Interpreter.vm; mutable across calls
    FocusedNodeID core.NodeID  // chunk 5 populates; chunk 4 leaves zero
}

type FlowNode struct {
    // ... chunk 2 fields ...
    FlowState *FlowState  // nil for non-interactive nodes; non-nil after OpenFlow
}
```

`FlowState` is on the FlowNode (in `game/interaction/tree/`). The
field type imports gopher-lua, which is already an allowed import
for `tree`. The interpreter package writes to this field via
`OpenFlow`; reads via `ProcessInput`.

`FocusedNodeID` is left as the zero value in chunk 4. Chunk 5
populates and updates it via spatial navigation queries.

#### D52. OpenFlow / no CloseFlow

```go
// game/interaction/interpreter/open.go

// OpenFlow initializes FlowState on an interactive flow node.
// Loads the schema module if not already cached, allocates a
// fresh empty Accum table, and sets CurrentStep to the schema's
// initial_step. Idempotent: a second OpenFlow on the same node
// is rejected with opInterpAlreadyOpen.
func (ip *Interpreter) OpenFlow(node *tree.FlowNode) error
```

Errors:
- `node == nil` → `opInterpNilNode`
- `node.Schema.Profile != ProfileInteractive` →
  `opInterpNotInteractive`
- `node.FlowState != nil` → `opInterpAlreadyOpen`
- Schema module fails to load → `opInterpSchemaLoadFailed`
- `node.Schema.Interactive.InitialStep` not in `Steps` is
  caught at chunk 1 schema validation; chunk 4 trusts this
  invariant.

On success:
- Allocates `accum := ip.vm.NewTable()` (empty).
- Sets `node.FlowState = &FlowState{CurrentStep: initialStep, Accum: accum}`.

**No `CloseFlow` method.** When `tree.Detach(node)` removes a
node, the FlowState struct goes with it. The Accum LTable on the
interpreter VM becomes unreachable from Go and is collected by
the LState's GC. Phase 6 does not need explicit accum release;
the gopher-lua runtime handles it.

If a chunk-N future need surfaces (e.g., explicit reset of all
accums on mode change), introduce CloseFlow then. Chunk 4 trusts
GC.

#### D53. ProcessInput API

```go
// game/interaction/interpreter/process.go

// FlowInput is the per-frame input descriptor for one ProcessInput call.
type FlowInput struct {
    Action    InputAction      // "select" | "confirm" | "cancel"
    Selection *SelectionData   // non-nil for Action=="select"
}

type InputAction string

const (
    InputActionSelect  InputAction = "select"
    InputActionConfirm InputAction = "confirm"
    InputActionCancel  InputAction = "cancel"
)

type SelectionData struct {
    Key   string
    Label string  // optional; passed through to the schema function
}

// ProcessInput runs one input event against an interactive flow
// node. Looks up the current step, dispatches by step type, calls
// the relevant schema function (on_select / on_confirm) with
// snapshot/rollback semantics, parses the returned directive, and
// returns a FlowResult.
//
// Cancel input is handled by the interpreter directly (no schema
// function for the step's `back` rule); the schema's on_cancel
// (if present) runs only when the entire flow exits via cancel.
func (ip *Interpreter) ProcessInput(node *tree.FlowNode, input FlowInput) (FlowResult, error)
```

Errors:
- `node == nil` → `opInterpNilNode`.
- `node.FlowState == nil` → `opInterpNotOpen` (caller must have
  called OpenFlow first).
- Closed interpreter → `opInterpClosed`.
- Step lookup fails (schema invariant violation) →
  `opInterpUnknownStep`.
- `input.Action` not in `{"select", "confirm", "cancel"}` →
  `opInterpUnknownInputAction`.
- `Action == "select"` with `Selection == nil` or
  `Selection.Key == ""` → `opInterpSelectMissingSelection`.
- `Action == "select"` on a non-`choice` step (e.g., on a
  `confirm` step) → `opInterpInvalidActionForStepType`.
- `Action == "confirm"` on a non-`confirm` step (e.g., on a
  `choice` step) → `opInterpInvalidActionForStepType`.
- `Action == "select"` on a `choice` step with static
  `options` whose keys do not contain `Selection.Key` →
  `opInterpInvalidSelection`.
- `Action == "select"` on a `choice` step with `options_fn`,
  options_fn raises a Lua error → `opInterpOptionsFnRaised`.
- `Action == "select"` on a `choice` step with `options_fn`
  whose return value is not a table-of-tables with `key` /
  `label` strings → `opInterpOptionsFnInvalidReturn`.
- `Action == "select"` on a `choice` step with `options_fn`
  where `Selection.Key` is not in the returned options →
  `opInterpInvalidSelection`.
- Schema function (on_select / on_confirm / on_complete /
  on_cancel) raises Lua error → snapshot rolled back, error
  wrapped as `opInterpFunctionRaised`.
- Directive shape invalid → `opInterpDirectiveInvalid` (further
  refined as `opInterpDirectiveUnknownKey`,
  `opInterpDirectiveConflict`, `opInterpUnknownTransition`,
  `opInterpPayloadXxx` per D57 / D60).

Chunk 5 will introduce `TreeManager.RouteInput(input)` that
locates the input-owning node via focus path and delegates to
`Interpreter.ProcessInput(node, input)`.

#### D54. ctx.view / ctx.params / accum projection per call

Schema functions receive three values (per design spec §2.6):
- `ctx.view` — read-only view_state
- `ctx.params` — immutable open-time parameters
- `accum` — mutable workspace

Per-call projection:

```go
// game/interaction/interpreter/ctx.go

func (ip *Interpreter) buildCtx(node *tree.FlowNode) (*lua.LTable, error) {
    ctx := ip.vm.NewTable()

    // ctx.view: deep-copy from DerivationVM into interpreter VM.
    // node.ViewState lives on DerivationVM; functions here run on
    // interpreter VM. Cross-VM table read uses defs.DeepCopyLuaTable
    // (Phase 5 helper exported per D62 / §7.3.2). MakeReadOnly is
    // applied so schema functions cannot mutate the copy.
    viewCopy, err := defs.DeepCopyLuaTable(ip.vm, node.ViewState)
    if err != nil { return nil, wrap(opInterpViewCopyFailed, err) }
    script.MakeReadOnly(ip.vm, viewCopy)
    ctx.RawSetString("view", viewCopy)

    // ctx.params: project from Go map[string]any into Lua. Persistent
    // nodes have OpenContext == nil per chunk 2 D24; treat that as
    // empty params (a fresh empty table, also MakeReadOnly'd so
    // schema functions can't mutate). Transient nodes have non-nil
    // OpenContext per chunk 2 D28.
    var paramsTbl *lua.LTable
    if node.OpenContext == nil {
        paramsTbl = ip.vm.NewTable()  // empty
    } else {
        paramsTbl, err = projectParams(ip.vm, node.OpenContext.Params)
        if err != nil { return nil, wrap(opInterpParamsProjectFailed, err) }
    }
    script.MakeReadOnly(ip.vm, paramsTbl)
    ctx.RawSetString("params", paramsTbl)

    return ctx, nil
}
```

**Read-only invariant (per D5).** Both `ctx.view` and `ctx.params`
are `script.MakeReadOnly`-wrapped (Phase 5 helper) so any
schema-function attempt to write `ctx.view.foo = ...` or
`ctx.params.bar = ...` raises a Lua error. The error is caught
by the snapshot/rollback machinery (D55) — accum reverts to
pre-call state; the ProcessInput caller receives
`opInterpFunctionRaised`.

`defs.DeepCopyLuaTable` mirrors Phase 5's existing internal
`deepCopyLuaTable`; chunk 4 exports it (D62 / §7.3.2). It walks
source LTable via Go-side accessors, builds equivalent LTable on
target VM. Supports LString, LNumber, LBool, LNil, LTable
(recursive). Rejects functions, userdata, metatables (see
§7.3.2 for the metatable behavior alignment).
Cycle-detected via visited set (managed internally by the public
helper; no map-passing required by the caller).

`projectParams` walks `map[string]any`:
- string → LString
- float64, int (and other Go numeric kinds) → LNumber
- bool → LBool
- nil → LNil
- map[string]any → LTable (recursive)
- []any → LTable with integer keys (Lua array)
- anything else → `opInterpParamsUnsupportedType`

Both helpers produce **fresh copies per call**. ctx.view and
ctx.params are not retained across ProcessInput invocations; they
are re-built every time.

**`accum` is NOT copied and is NOT MakeReadOnly'd.** The Accum
LTable from `node.FlowState.Accum` is passed directly as the
third argument to the schema function. Schema function mutates it
in place; mutations persist across ProcessInput calls (per design
spec §2.6). The snapshot/rollback machinery (D55) protects the
caller from partial mutations on error.

#### D55. Accum mutability + snapshot/rollback

Per design spec §2.6 mutation contract:
- **During ProcessInput**: Accum is mutable by the schema function.
- **Between calls**: Accum is read-only (no Go-side mutation).
- **On schema function error**: Accum is restored to a snapshot
  taken before the call.

Snapshot mechanism:

```go
// game/interaction/interpreter/snapshot.go

// snapshotAccum produces a deep copy of accum on the same VM,
// suitable for restoration if the schema function errors.
func snapshotAccum(L *lua.LState, accum *lua.LTable) (*lua.LTable, error)

// restoreAccum overwrites the entries of `target` with the entries
// of `source`. target is the live FlowNode.FlowState.Accum; source
// is the snapshot taken before the call. Restoration uses Go-side
// LTable mutation (clear and copy via ForEach).
func restoreAccum(target, source *lua.LTable)
```

ProcessInput flow:
1. Take snapshot of `node.FlowState.Accum`.
2. Call schema function.
3. On error: `restoreAccum(node.FlowState.Accum, snapshot)`,
   wrap and return error.
4. On success: snapshot is discarded; mutated accum is the new
   live state.

`CurrentStep` is **not** part of rollback. The interpreter only
commits step changes after the directive is parsed successfully.
Pseudocode:

```
oldStep := node.FlowState.CurrentStep
oldSnap := snapshotAccum(L, accum)

ret, err := callSchemaFn(...)
if err != nil:
    restoreAccum(accum, oldSnap)
    return wrap(err)

result, err := parseDirective(ret)
if err != nil:
    restoreAccum(accum, oldSnap)
    // CurrentStep was not yet changed
    return wrap(err)

// commit: directive parsed successfully
if result.NextStep != "":
    node.FlowState.CurrentStep = result.NextStep
return result, nil
```

This matches design spec §2.6's "Snapshot / rollback" section:
> The interpreter snapshots Accum before calling ProcessInput.
> If the schema function errors, Accum is restored to the
> snapshot. CurrentStep is not part of rollback because the
> interpreter only commits step changes after successful directive
> processing.

#### D56. Step type dispatch — `choice` and `confirm`

Based on the current step's type (from `Schema.Interactive.Steps[CurrentStep].Type`):

**`choice` step:**
- `Action == InputActionSelect`: validate `Selection.Key` against
  the step's options (static OR dynamic — both paths validated;
  see D59). If valid, build `selection` table from FlowInput.Selection,
  call `on_select(ctx, accum, selection)`. Invalid Key raises
  `opInterpInvalidSelection` *before* on_select is called, so
  no Accum mutation occurs.
- `Action == InputActionCancel`: handle via step.Back rule (no
  schema function called for back navigation).

**`confirm` step:**
- `Action == InputActionConfirm`: call `on_confirm(ctx, accum)`.
- `Action == InputActionSelect`: not valid for confirm steps →
  `opInterpInvalidActionForStepType`.
- `Action == InputActionCancel`: handle via step.Back rule.

**Cancel handling (any step):**
- If `step.Back == ""`: ignore the cancel (return
  `FlowResult{Cancelled: false, Completed: false}`, no step
  change). Authoring guidance: most steps should set Back.
- If `step.Back == "cancel"`: emit
  `FlowResult{Cancelled: true}`. If schema has `on_cancel`,
  call it next per D57.
- Else: `step.Back` is a step ID; set `node.FlowState.CurrentStep
  = step.Back`, return `FlowResult{Completed: false, Cancelled:
  false}` (no schema function called, no outcome).

Unknown step type at runtime → `opInterpUnknownStepType` (should
be caught by chunk 1 schema validation; this guard is defensive).

#### D57. Directive parse + validation; on_complete / on_cancel

The schema function returns a Lua table. Recognized directive
keys (any combination, with mutual-exclusion rules):

| Key            | Lua type       | Effect                                                    |
|----------------|----------------|-----------------------------------------------------------|
| `next`         | string         | Set CurrentStep to this value (must be a step ID)         |
| `complete`     | bool           | Flow completes; trigger `on_complete` if defined          |
| `cancel`       | bool           | Flow cancels; trigger `on_cancel` if defined              |
| `commands`     | array of {type=string, payload=table} | Append to FlowOutcome.Commands             |
| `host_effects` | array of host effect descriptors      | Append to FlowOutcome.HostEffects (chunk 4 supports a minimal set; see below) |
| `transition`   | string         | Set FlowOutcome.Transition to the named GameMode          |
| `quit`         | bool           | FlowOutcome.QuitRequested = true                          |
| `open_flow`    | table {schema_id=string, params=table} | FlowOutcome.OpenFlow set                  |

Mutual exclusion:
- `next` is exclusive with `complete` and `cancel`.
- `complete` is exclusive with `cancel`.
- `commands`, `host_effects`, `transition`, `quit`, `open_flow`
  may co-occur with any of `next`/`complete`/`cancel`.

Unknown keys → `opInterpDirectiveUnknownKey` listing the unknown
key name.

**`on_complete` / `on_cancel` semantics** (chunk 1 deferred):
- When directive has `complete = true`: interpreter calls the
  schema's top-level `on_complete(ctx, accum)` if defined. Its
  return value is **also a directive table** with the same
  shape, parsed and merged into the FlowResult. This is the
  "completion handler" that may emit transitions, commands, or
  open_flow as the flow exits cleanly.
- When directive has `cancel = true`: interpreter calls
  `on_cancel(ctx, accum)` similarly.
- If `on_complete` itself emits `complete = true` or `cancel = true`
  in its directive, those are ignored (you can't re-complete a
  completed flow). It can still emit transition, commands, etc.

Snapshot-on-on_complete: the same accum-snapshot rule applies.
on_complete runs against the post-step accum (which may have been
mutated by on_select). Errors in on_complete revert accum to the
snapshot taken before on_select.

**Host effect descriptor shape** (locked).

The engine's `pipeline.HostRequest` is verified at
[host_effect_types.go:13](engine/pipeline/host_effect_types.go#L13):

```go
type HostRequest struct {
    Kind               HostRequestKind  // SetWindowMode | OpenFlow | WarnNotImplemented
    WindowMode         WindowModeSpec
    OpenFlow           FlowOpenSpec
    WarnNotImplemented string
}

type WindowModeSpec struct {
    Mode           WindowMode  // Unspecified | Windowed | BorderlessWindowed | Fullscreen
    ClientWidthPx  int
    ClientHeightPx int
}
```

Chunk 4 supports **one** authored host-effect kind:
`set_window_mode`. Mapping:

```lua
-- Authored Lua:
host_effects = {
    { kind = "set_window_mode", mode = "windowed" },                       -- bare
    { kind = "set_window_mode", mode = "fullscreen", width = 1920, height = 1080 },  -- with size
}
```

→

```go
pipeline.HostRequest{
    Kind: pipeline.HostRequestSetWindowMode,
    WindowMode: pipeline.WindowModeSpec{
        Mode:           // mapped from the "mode" string
        ClientWidthPx:  // mapped from "width", 0 if absent
        ClientHeightPx: // mapped from "height", 0 if absent
    },
}
```

`mode` string mapping (locked):
- `"windowed"` → `pipeline.WindowModeWindowed`
- `"borderless"` → `pipeline.WindowModeBorderlessWindowed`
- `"fullscreen"` → `pipeline.WindowModeFullscreen`
- Any other string → `opInterpHostEffectInvalidMode`.

`width` / `height` are optional. If present, must be positive
integers; else `opInterpHostEffectInvalidSize`. If absent, the
spec fields stay zero (engine treats zero as "use current").

`kind` values other than `"set_window_mode"` →
`opInterpHostEffectUnknownKind`. Chunks that need additional
kinds (e.g., for cutscene transitions) extend the parser then.

The `OpenFlow` host-request kind (`pipeline.HostRequestOpenFlow`)
is NOT used directly via `host_effects` in chunk 4 — flow opens
go through the directive's `open_flow` field instead (D58).
`WarnNotImplemented` is engine-internal and not exposed to
schema authors.

Tests assert the bare and with-size forms each map to the
correct `pipeline.HostRequest` value.

#### D58. FlowOutcome + FlowResult Go shapes

Per design spec §13, with chunk 4 corrections to the OpenFlow
shape and the transition lookup:

```go
// game/interaction/interpreter/outcome.go

type FlowOutcome struct {
    Commands      []command.CommandRequest
    HostEffects   []pipeline.HostRequest
    Transition    *mode.GameMode      // nil if no transition
    OpenFlow      *OpenFlowRequest    // nil if no open_flow directive
    QuitRequested bool
}

type FlowResult struct {
    Completed        bool
    Cancelled        bool
    Outcome          *FlowOutcome      // non-nil when Completed or any outcome field set
    ImmediateEffects []pipeline.HostRequest
}

// OpenFlowRequest is the parsed form of an open_flow directive. It
// carries both the target schema_id and the open-time params; the
// design spec §13's `*OpenContext` shorthand was incomplete because
// tree.OpenContext only carries Params (no SchemaID).
type OpenFlowRequest struct {
    SchemaID    string
    OpenContext tree.OpenContext  // tree.OpenContext.Params populated
                                  // from the directive's params table
}
```

**`ImmediateEffects`** is reserved for the design spec's notion of
host effects that apply immediately (current frame) vs. deferred
(`Outcome.HostEffects`). For chunk 4, the simple rule: directive's
`host_effects` go into `Outcome.HostEffects` (deferred). If a
specific host effect needs to be immediate, that's a chunk-tbd
decision when HandleFrame integration lands.

**Transition lookup.** Directive's `transition = "battle"` is
mapped to `mode.GameMode` via an interpreter-local string map.
`game/mode` does not currently provide a `LookupGameMode`
function (verified in [game/mode/mode.go](game/mode/mode.go) —
the package only declares `GameMode` constants and an
`AllGameModes` slice). Chunk 4 introduces the local map:

```go
// game/interaction/interpreter/outcome.go

var transitionsByName = map[string]mode.GameMode{
    "title":     mode.ModeTitle,
    "overworld": mode.ModeOverworld,
    "battle":    mode.ModeBattle,
}

func resolveTransition(name string) (*mode.GameMode, error) {
    m, ok := transitionsByName[name]
    if !ok {
        return nil, opInterpUnknownTransition (path: name)
    }
    return &m, nil
}
```

Adding new `GameMode` values (per `mode.AllGameModes`) requires
extending this map. A defensive test cross-checks that every
`mode.AllGameModes` value has an entry; otherwise → test
failure as a coverage forgot. (When the inventory and the map
align, chunk-tbd may consolidate this lookup into `game/mode`
itself.)

**OpenFlow shape (locked).** Directive's
`open_flow = { schema_id = "...", params = {...} }` parses into
`*OpenFlowRequest{SchemaID, OpenContext}`. `SchemaID` is required
(non-empty string); else `opInterpOpenFlowMissingSchemaID`.
`params` is optional; if absent, `OpenContext.Params` is an
empty map. If present, it's projected from Lua to Go via the
inverse of D54's `projectParams`.

#### D59. Static options re-read at runtime

Chunk 1's schema registry holds static `Options` arrays for choice
steps as a snapshot, used for **validation only** (chunk 1 D17 +
D59 here lock this).

At runtime, the interpreter re-reads the schema fresh:

1. `require(scriptKey)` returns the schema table (cached by
   gopher-lua's require).
2. Walk `schema.steps[currentStep]` to find `options` or
   `options_fn`.
3. Build the option-keys set:
   - **Static path** (`options` array present): collect each
     entry's `key` field into a set. (`options` array iteration
     uses `script.UnwrapReadOnly` if needed; static authored
     options on the schema module are not wrapped, but defensive
     unwrapping keeps the option-iterator helper uniform.)
   - **Dynamic path** (`options_fn` present): the validator
     wraps the `options_fn` call in its own snapshot/restore
     bracket so any mutations options_fn makes to `accum` do
     **not** persist (success or failure):
     ```
     prefn := snapshotAccum(L, accum)
     ret, err := callOptionsFn(...)
     restoreAccum(accum, prefn)  // unconditional
     if err != nil: return opInterpOptionsFnRaised
     ```
     If `ret` is not a table-of-tables with `key` and `label`
     strings, return `opInterpOptionsFnInvalidReturn`.
     Otherwise, walk the returned table via `script.UnwrapReadOnly`
     (D62) — `options_fn` may return a read-only proxy
     (e.g., `return ctx.view.available_actions`), and Go-side
     `ForEach` on a proxy yields nothing without unwrapping.
     For each entry, also unwrap before reading `key` (the entry
     itself is a proxy if the parent was). Collect each `key`
     into the set.
4. Validate that `FlowInput.Selection.Key` is in the set; if
   not, return `opInterpInvalidSelection` (no on_select call,
   no accum mutation).

This avoids cross-VM staleness (the registry's snapshot is on the
loader VM which is closed; can't reuse). Single source of truth:
the schema module loaded in the interpreter VM.

**Both static and dynamic options are validated.** An earlier
draft of D56 said dynamic options were trusted; that contradicted
this section. The reconciled rule: dynamic options are validated
by calling `options_fn` and checking the returned set, the same
way static options are validated by reading the static array.

For static `options`, every ProcessInput re-reads the same array.
Cheap (Lua table accessors). No caching by chunk 4; if perf is a
concern later, add a per-flow cache.

For dynamic `options_fn`, the function is called twice per
ProcessInput (once for option validation here, once during
rendering — though chunk 4 doesn't render). Authoring guidance:
`options_fn` should be deterministic and side-effect-free given
ctx.view + accum; chunks 4-7 don't try to enforce determinism.

**Mutation isolation is enforced.** The validator wraps
`options_fn` in its own snapshot/restore bracket so any accum
mutation by `options_fn` is unconditionally rolled back —
success or failure. This is stronger than the on_select path
(where accum mutations DO persist on success). Authors can
still mutate accum in options_fn, but the change is invisible
afterward; tests verify this.

#### D60. Lua → JSON payload marshaller

`command_panel.lua` directive:
```lua
return { commands = { { type = "battle_action", payload = accum } } }
```

`payload = accum` requires Lua table → []byte conversion.
`command.CommandRequest{Type, Payload []byte}` is the engine
target. JSON is the natural encoding given accum's JSON-like
constraint (D5 cross-cutting).

Chunk 4 introduces `payload.go`:

```go
// MarshalLuaPayload converts a Lua value (table, string, number,
// bool, or nil) into a JSON []byte. Validates the value is JSON-like:
// no functions, no userdata, no metatables, no cycles.
func MarshalLuaPayload(v lua.LValue) ([]byte, error)
```

JSON-like Lua subset (mirrors design spec §2.6 accum constraint):
- LNil → JSON null
- LBool → JSON true/false
- LNumber → JSON number
- LString → JSON string
- LTable, **non-empty array form** (consecutive integer keys
  1..N, no string keys) → JSON array
- LTable, **non-empty dict form** (string keys, no integer keys)
  → JSON object
- LTable, **empty** (no keys at all) → JSON object `{}` —
  empty-array vs empty-object is ambiguous in Lua; chunk 4 picks
  object as the safer default. Authors who need an empty array
  use a one-element array and discard, or rely on engine-side
  conventions to be locked when needed.
- Mixed array/dict tables (both integer and string keys) →
  `opInterpPayloadMixedTable`
- LFunction, LUserData, *Channel → `opInterpPayloadUnsupportedType`
- Cycle detected → `opInterpPayloadCycle`

The marshaller is invoked once per command in the directive. Each
command's `payload` field is the marshalled []byte; `type` is the
authored string verbatim.

#### D61. Authored content upgrades — `command_panel.lua`, `title_menu.lua`

Chunk 2 authored these as passive stubs. Chunk 4 upgrades both to
interactive profile.

**`command_panel.lua` (rewritten for chunk 4):**

```lua
return {
    id = "command_panel",
    persistent = true,
    interactive = true,
    focus_entry = "first_focusable",

    initial_step = "action_select",
    steps = {
        action_select = {
            type = "choice",
            options_fn = function(ctx, accum)
                return ctx.view.available_actions or {}
            end,
            on_select = function(ctx, accum, selection)
                accum.action = selection.key
                return { complete = true }
            end,
            back = "cancel",
        },
    },

    on_complete = function(ctx, accum)
        return {
            commands = {
                { type = "battle_action", payload = accum },
            },
        }
    end,
}
```

Mirrors design spec §11.2 verbatim (with `or {}` defensive on
options_fn for tests where view.available_actions might be nil).

**`title_menu.lua` (rewritten for chunk 4):**

```lua
return {
    id = "title_menu",
    persistent = true,
    interactive = true,
    focus_entry = "first_focusable",
    anchor = { kind = "center" },

    initial_step = "main",
    steps = {
        main = {
            type = "choice",
            options = {
                { key = "new_game", label = "New Game" },
                { key = "quit",     label = "Quit" },
            },
            on_select = function(ctx, accum, selection)
                accum.action = selection.key
                return { complete = true }
            end,
            back = "cancel",
        },
    },

    on_complete = function(ctx, accum)
        if accum.action == "new_game" then
            return { transition = "overworld" }
        elseif accum.action == "quit" then
            return { quit = true }
        end
    end,
}
```

Mirrors design spec §11.4 verbatim.

`pause_menu.lua` (chunk 1) is already interactive — unchanged.

These rewrites are profile changes: chunk 2's passive
configurations become chunk 4's interactive ones. Chunk 1's
schema validator handles both profiles (D14-D17), so loading still
passes.

**No production derivation files are authored in chunk 4.**
`command_panel`'s `options_fn` reads `ctx.view.available_actions`,
but the production `command_panel` derivation (which would query
ECS for the active fighter's available actions) lands with the
battle proof chunk (chunk 6 or wherever the full battle round
trip is wired). Chunk 4's tests use synthetic test derivations
(authored inline in test fixtures) that supply
`{available_actions = ...}` directly. This keeps chunk 4 focused
on the interpreter mechanism without expanding scope into ECS
content.

#### D62. Helper exports — `defs.DeepCopyLuaTable` + `script.UnwrapReadOnly`

Chunk 4 needs two cross-package helpers that already exist as
internal/private symbols in their respective packages. Both are
exported per D10's amended additive-helper rule.

**`game/defs.DeepCopyLuaTable`** (Phase 5 surface change):

```go
// game/defs/assemble.go (modified in chunk 4)
//
// DeepCopyLuaTable produces an independent copy of src on
// targetL. Source values may live on any LState (cross-VM safe);
// the result lives on targetL. Supports LString, LNumber, LBool,
// LNil, and LTable (recursive). Rejects LFunction, LUserData,
// channel values, and any source value with a metatable.
// Cycles are detected and rejected. The visited set is managed
// internally; callers do not pass it.
func DeepCopyLuaTable(targetL *lua.LState, src *lua.LTable) (*lua.LTable, error)
```

Wraps the existing internal `deepCopyLuaTable(targetL, src, visited)`
by allocating a fresh visited map and delegating. The exported
wrapper is one new function. The internal helper stays as-is
EXCEPT for one tightening: chunk 4 adds metatable rejection if
the existing implementation doesn't already check (per spec
contract above; small implementation change with no behavioral
effect on existing Phase 5 callers because authored content
doesn't use metatables).

Used by chunk 4 in:
- `interpreter/ctx.go` — cross-VM copy of view_state from
  DerivationVM into interpreter VM (D54).
- `interpreter/snapshot.go` — same-VM copy of accum for
  snapshot/rollback (D55).

**`engine/script.UnwrapReadOnly`** (engine surface change):

```go
// engine/script/readonly.go (modified in chunk 4)
//
// UnwrapReadOnly returns the underlying shadow table if t is a
// read-only proxy created by MakeReadOnly, or t unchanged if not.
// A "read-only proxy" is detected by the metatable signature
// MakeReadOnly installs (`__metatable = "protected"` plus
// `__index` pointing to a shadow LTable). Identical detection
// logic to the existing private resolveCodecProxy in
// codec_reverse.go; chunk 4 lifts the check into a public helper.
//
// Returns t unchanged for non-proxied tables.
func UnwrapReadOnly(t *lua.LTable) *lua.LTable
```

The existing private `resolveCodecProxy` (in
`engine/script/codec_reverse.go`) is the reference implementation.
Chunk 4 either (a) renames it to `UnwrapReadOnly` and exports it,
moving it to `readonly.go`, or (b) adds a new exported wrapper
that delegates to the private function. Implementer's choice.
The `codec_reverse.go` callers continue to compile against
whichever name lands.

**Why the export is needed.** D54 applies `MakeReadOnly` to
`ctx.view` to enforce the read-only contract from D5. After
MakeReadOnly, the LTable is a proxy: outer keys empty, real data
behind a `__index` shadow. Lua-side reads (`ctx.view.x`) work
through `__index`. **Go-side `LTable.ForEach` returns nothing** —
the proxy's RawXxx accessors see the empty outer table.

This breaks D59's option validation. When `options_fn` returns
`ctx.view.available_actions` (also a read-only proxy because
`MakeReadOnly` is recursive), the interpreter's Go-side
ForEach-based validation walks an empty table and rejects every
selection key.

Chunk 4 fixes this by:
1. Calling `script.UnwrapReadOnly(returnedTable)` once on the
   `options_fn` return value.
2. Iterating the unwrapped table to collect option keys.

The unwrap is **shallow** — it returns the shadow of the top-level
proxy. If the shadow contains nested LTable values that are
themselves proxies (which they will be, because MakeReadOnly is
recursive), the validator must unwrap each entry as it walks,
or use Lua-side iteration. For chunk 4's option-key collection,
each option entry is `{key=..., label=...}` — a small dict-shaped
table; the validator unwraps each entry once before reading
`key`. Lock the recursive-unwrap pattern in §7.3.6 implementation
notes.

Used by chunk 4 in:
- `interpreter/process.go` — option validation walking
  `options_fn` return values.

#### D63. Layer guard rule for `game/interaction/interpreter`

```go
{
    ImporterBase: modulePath + "/game/interaction/interpreter",
    ForbiddenBase: []string{
        modulePath + "/engine/runtime",
        modulePath + "/engine/sim",
        modulePath + "/engine/world",
        modulePath + "/engine/presentation",
        modulePath + "/engine/overlay",
        modulePath + "/engine/text",
        modulePath + "/engine/display",
        modulePath + "/engine/animation",
        modulePath + "/engine/session",
        modulePath + "/engine/errorsink",
        modulePath + "/engine/clock",
        modulePath + "/engine/ds",
        modulePath + "/engine/fs",
        modulePath + "/engine/ui",       // no UI deps in interpreter
        modulePath + "/game/menu",
        modulePath + "/game/flow",
        modulePath + "/game/ui",
        modulePath + "/game/presentation",
        modulePath + "/game/proposals",
        modulePath + "/game/snapshot",
        modulePath + "/game/uidef",
        modulePath + "/game/worldstreaming",
        modulePath + "/render",
        modulePath + "/platform",
    },
    ExactImport: []string{
        modulePath + "/game",
    },
    Rule:        "layers.md: game/interaction/interpreter may import engine/assets, engine/coretypes, engine/command, engine/pipeline, engine/script, game/defs, game/interaction/schema, game/interaction/tree, game/mode, failures, and gopher-lua only",
    Remediation: "interpreter is the runtime executor for interactive schema functions; UI deps belong in render package; engine/runtime / engine/pipeline non-HostRequest surfaces stay out",
},
```

Allowed imports (by absence from forbidden, plus narrowness):
- `engine/assets`, `engine/coretypes`, `engine/command`,
  `engine/pipeline` (only HostRequest used; whole package
  permitted), `engine/script`
- `game/defs`, `game/interaction/schema`, `game/interaction/tree`,
  `game/mode`
- `failures`, `gopher-lua`

Forbidden by-prefix `engine/ui` and `game/ui` blanket: interpreter
has no UI rendering concerns. Chunk 5 routing may need
`engine/ui/query` for spatial nav, but that lives in `routing/`,
not `interpreter/`.

### 7.2 Chunk 4 acceptance

#### 7.2.1 New files

```
game/interaction/interpreter/
  interpreter.go
  open.go
  process.go
  outcome.go
  ctx.go
  snapshot.go
  payload.go
  errors.go
  interpreter_test.go
  open_test.go
  process_test.go
  outcome_test.go
  payload_test.go
  testhelpers_test.go
```

#### 7.2.2 Modified files

- `game/assets/scripts/game/interaction/schemas/command_panel.lua`
  — passive stub rewritten as interactive (D61).
- `game/assets/scripts/game/interaction/schemas/title_menu.lua`
  — passive stub rewritten as interactive (D61).
- `game/interaction/tree/tree.go` — `FlowState` field added to
  `FlowNode`; `FlowState` struct introduced.
- `game/interaction/tree/manager_test.go` — chunk 2 tests
  unaffected unless they assert FlowNode shape; if they do, add
  `FlowState: nil` for non-interactive nodes.
- `game/defs/assemble.go` — export `DeepCopyLuaTable` (one new
  exported wrapper, plus metatable-rejection check in the
  internal helper if absent). See D62 / §7.3.2.
- `engine/script/readonly.go` — add (or move and rename) exported
  `UnwrapReadOnly(t *lua.LTable) *lua.LTable`. The existing
  private `resolveCodecProxy` in
  `engine/script/codec_reverse.go` is the reference
  implementation. See D62.
- `engine/script/codec_reverse.go` — if `resolveCodecProxy` is
  renamed to `UnwrapReadOnly` and moved to readonly.go, this
  file's call sites update to use the new name. If a wrapper
  pattern is chosen instead, this file is unchanged.
- `internal/importguard/import_guard_test.go` — add
  `game/interaction/interpreter` rule (D63).
- `game/assets/catalog.json` — regenerated by `tools/cataloggen/`.
- (No HandleFrame integration files modified; deferred per D11/D45.)

#### 7.2.3 Test matrix

`interpreter_test.go` (lifecycle):
- `TestNew_NilScripts` → `opInterpNilScripts`.
- `TestNew_NilSchemas` → `opInterpNilSchemas`.
- `TestNew_BuildsSandboxedVM` — verify VM globals: stdlib subset
  loaded, dangerous globals removed, math.random no-op.
- `TestClose_Idempotent` — Close twice; second call no-op, no panic.
- `TestPostClose_OpenFlowRejected` → `opInterpClosed`.
- `TestPostClose_ProcessInputRejected` → `opInterpClosed`.

`open_test.go`:
- `TestOpenFlow_NilNode` → `opInterpNilNode`.
- `TestOpenFlow_PassiveNode_Rejected` → `opInterpNotInteractive`.
- `TestOpenFlow_RootNode_Rejected` → `opInterpNotInteractive`
  (root profile is also non-interactive).
- `TestOpenFlow_AlreadyOpen_Rejected` → `opInterpAlreadyOpen`.
- `TestOpenFlow_InitialFlowState` — after OpenFlow,
  `node.FlowState.CurrentStep` matches `Schema.Interactive.InitialStep`,
  `Accum` is empty non-nil LTable, `FocusedNodeID` is zero.
- `TestOpenFlow_AccumIsFreshTableEachOpen` — open + close (via
  detach+reattach) → new accum, no leakage from prior accum.
- `TestOpenFlow_SchemaModuleLoadFailed` — schema registry built
  from a script reader with a *valid* schema source for
  `script.game.interaction.schemas.x`; interpreter constructed
  from a *different* script reader where the same key returns
  invalid Lua (e.g., a syntax error). OpenFlow on a node whose
  schema is `x` then fails with `opInterpSchemaLoadFailed`. This
  test does not use `newTestInterpreter`; it constructs the
  registry and interpreter directly with the two readers.

`process_test.go`:
- `TestProcessInput_NilNode` → `opInterpNilNode`.
- `TestProcessInput_NotOpen` (FlowState nil) → `opInterpNotOpen`.
- `TestProcessInput_UnknownAction` (Action="bogus") →
  `opInterpUnknownInputAction`.
- `TestProcessInput_SelectMissingSelection` (Selection==nil or
  Selection.Key=="") → `opInterpSelectMissingSelection`.
- `TestProcessInput_SelectOnConfirmStep_Rejected` →
  `opInterpInvalidActionForStepType`.
- `TestProcessInput_ConfirmOnChoiceStep_Rejected` →
  `opInterpInvalidActionForStepType`.
- `TestProcessInput_ChoiceStep_OnSelectInvoked` — synthetic choice
  step + on_select that mutates accum and returns `{ complete = true }`;
  assert accum mutation persisted, FlowResult.Completed=true.
- `TestProcessInput_ChoiceStep_NextDirective` — on_select returns
  `{ next = "step2" }`; assert `node.FlowState.CurrentStep == "step2"`,
  Completed=false.
- `TestProcessInput_ChoiceStep_StaticOptions_KeyValidated` —
  selection.Key not in static options → `opInterpInvalidSelection`.
- `TestProcessInput_ChoiceStep_DynamicOptions_KeyValidated` —
  options_fn returns a list; selection.Key NOT in it →
  `opInterpInvalidSelection`. Selection.Key IN the list → on_select
  is invoked.
- `TestProcessInput_ChoiceStep_OptionsFnReturnsCtxViewProxy` —
  the schema sets `ctx.view.available_actions = {{key="attack",label="Attack"}}`
  (via test-fixture derivation); `options_fn` returns
  `ctx.view.available_actions` directly; the validator
  unwraps the read-only proxy via `script.UnwrapReadOnly` and
  accepts `Selection.Key="attack"`. This is the regression test
  for the read-only proxy issue D62 addresses.
- `TestProcessInput_ChoiceStep_OptionsFnRaised` — options_fn raises
  Lua error → `opInterpOptionsFnRaised`; accum reverts (note:
  options_fn's own accum mutations are also reverted by the
  per-options_fn snapshot, in addition to the outer ProcessInput
  snapshot).
- `TestProcessInput_ChoiceStep_OptionsFnInvalidReturn` — options_fn
  returns a non-table or table-of-non-tables →
  `opInterpOptionsFnInvalidReturn`.
- `TestProcessInput_ChoiceStep_OptionsFnMutationDoesNotPersist` —
  options_fn body mutates `accum` (e.g., sets `accum.tampered = true`)
  then returns valid options including the selected key; on_select
  runs and completes successfully; assert `accum.tampered` is NOT
  set on the final FlowState.Accum (the per-options_fn snapshot
  rolled it back before on_select ran).
- `TestProcessInput_ConfirmStep_OnConfirmInvoked` — synthetic
  confirm step; on_confirm returns `{ complete = true }`.
- `TestProcessInput_CancelInput_BackToStep` — step.Back is a step
  ID; assert CurrentStep changes, no schema function called.
- `TestProcessInput_CancelInput_BackCancel_TriggersOnCancel` —
  step.Back == "cancel", schema has on_cancel; assert on_cancel
  called, FlowResult.Cancelled=true.
- `TestProcessInput_CancelInput_BackEmpty_NoOp` — step.Back == "";
  no FlowResult mutation.
- `TestProcessInput_PersistentInteractiveNode_NilOpenContext` —
  `command_panel`-shaped node (persistent + interactive) with
  `OpenContext == nil`; ctx.params is exposed as an empty
  read-only table (no panic).
- `TestProcessInput_CtxViewIsReadOnly` — schema function attempts
  `ctx.view.foo = "x"`; ProcessInput returns
  `opInterpFunctionRaised` with the read-only error message;
  accum reverts.
- `TestProcessInput_CtxParamsIsReadOnly` — schema function attempts
  `ctx.params.bar = "x"`; same outcome.
- `TestProcessInput_DirectiveCommands_Marshalled` — directive has
  commands with payload=accum; assert FlowResult.Outcome.Commands
  has correct Type and Payload bytes equal to expected JSON.
- `TestProcessInput_DirectiveTransition_LookupOk` — directive
  has transition="overworld"; FlowOutcome.Transition non-nil and
  equals `&mode.ModeOverworld`.
- `TestProcessInput_DirectiveTransition_UnknownMode` →
  `opInterpUnknownTransition` for an unknown name.
- `TestProcessInput_TransitionMapCoversAllGameModes` — defensive
  test asserting every value in `mode.AllGameModes` has a
  reverse-lookup entry in the interpreter's `transitionsByName`
  map. Catches the "added a new GameMode but forgot the map"
  failure mode.
- `TestProcessInput_DirectiveQuit` — `{ quit = true }`;
  FlowOutcome.QuitRequested=true.
- `TestProcessInput_DirectiveOpenFlow` — `{ open_flow = {schema_id="..", params={...}} }`;
  FlowOutcome.OpenFlow.SchemaID + .OpenContext.Params correct.
- `TestProcessInput_DirectiveOpenFlow_MissingSchemaID` —
  `{ open_flow = { params = {...} } }` → `opInterpOpenFlowMissingSchemaID`.
- `TestProcessInput_DirectiveOpenFlow_NoParams` —
  `{ open_flow = { schema_id = "x" } }` → OpenContext.Params is
  empty map (not nil).
- `TestProcessInput_DirectiveHostEffect_SetWindowMode_Bare` —
  authored `{kind="set_window_mode", mode="windowed"}` → expected
  `pipeline.HostRequest{Kind: HostRequestSetWindowMode, WindowMode: WindowModeSpec{Mode: WindowModeWindowed}}`.
- `TestProcessInput_DirectiveHostEffect_SetWindowMode_WithSize` —
  same with `width=1920, height=1080` → ClientWidthPx + ClientHeightPx populated.
- `TestProcessInput_DirectiveHostEffect_SetWindowMode_InvalidMode` —
  `mode="bogus"` → `opInterpHostEffectInvalidMode`.
- `TestProcessInput_DirectiveHostEffect_UnknownKind` —
  `{kind="unimplemented"}` → `opInterpHostEffectUnknownKind`.
- `TestProcessInput_DirectiveUnknownKey` →
  `opInterpDirectiveUnknownKey`.
- `TestProcessInput_DirectiveMutuallyExclusiveKeys` —
  `{ next="x", complete=true }` → `opInterpDirectiveConflict`.
- `TestProcessInput_OnSelectRaises_AccumRolledBack` — on_select
  mutates accum then errors; accum reverts to pre-call state;
  CurrentStep unchanged.
- `TestProcessInput_DirectiveMalformed_AccumRolledBack` — on_select
  mutates accum then returns invalid directive;
  accum reverts, error propagates.
- `TestProcessInput_OnComplete_RunsAfterCompleteDirective` —
  schema has on_complete; on_select returns `{complete=true}`;
  on_complete runs and contributes to FlowResult.

`outcome_test.go`:
- `TestParseDirective_AllRecognizedKeys` — table-driven test for
  each directive key.
- `TestParseDirective_HostEffectsParsedAsHostRequest` — pause_menu's
  `set_window_mode` host effect parses to the locked
  `pipeline.HostRequest` mapping per D57 (Kind, WindowMode.Mode,
  optional ClientWidthPx / ClientHeightPx).

`payload_test.go`:
- `TestMarshalLuaPayload_Nil` → `[]byte("null")`.
- `TestMarshalLuaPayload_Number_String_Bool`.
- `TestMarshalLuaPayload_DictTable`.
- `TestMarshalLuaPayload_ArrayTable`.
- `TestMarshalLuaPayload_NestedDict`.
- `TestMarshalLuaPayload_EmptyTable` → `[]byte("{}")` (empty table
  marshals as JSON object, not array; locked per D60).
- `TestMarshalLuaPayload_MixedTable_Rejected` →
  `opInterpPayloadMixedTable`.
- `TestMarshalLuaPayload_Function_Rejected` →
  `opInterpPayloadUnsupportedType`.
- `TestMarshalLuaPayload_Cycle_Rejected` →
  `opInterpPayloadCycle`.

#### 7.2.4 Integration proof

`game/tree_integration_test.go` extended again (chunks 2 + 3
already grew it):

`TestInterpreter_PauseMenu_FullCycle`:

1. Setup as in chunks 2-3: schemas, defs, derivation, components
   registries; populated ECS; DerivationVM + ComponentVM;
   TreeManager built from `battle_root`.
2. Construct an `*Interpreter`.
3. `tm.Attach("pause_menu", tree.OpenContext{})`.
4. `interp.OpenFlow(pauseMenuNode)`. Assert FlowState initialized
   with CurrentStep="main", empty accum.
5. `interp.ProcessInput(pauseMenuNode, FlowInput{Action: InputActionSelect, Selection: &SelectionData{Key: "settings"}})`.
   Assert FlowResult.Completed=false; CurrentStep is now "settings".
6. `ProcessInput(..., Selection: &SelectionData{Key: "back"})`.
   Assert CurrentStep now "main" (back navigated).
7. `ProcessInput(..., Selection: &SelectionData{Key: "resume"})`.
   Assert FlowResult.Cancelled=true (per pause_menu's `{cancel=true}`).
8. `interp.Close()`.

`TestInterpreter_CommandPanel_BattleActionPayload`:

1. Setup: same registries; configure derivation for command_panel
   so view_state.available_actions exists (synthetic test
   derivation: `return { available_actions = {{key="attack",label="Attack"}, {key="defend",label="Defend"}} }`).
2. Build TreeManager from "battle_root"; UpdateViewStates so
   command_panel has the available_actions.
3. `interp.OpenFlow(commandPanelNode)`.
4. `interp.ProcessInput(commandPanelNode, FlowInput{Action: InputActionSelect, Selection: &SelectionData{Key: "attack"}})`.
5. Assert FlowResult.Completed=true.
6. Assert FlowResult.Outcome.Commands[0]:
   - Type = "battle_action"
   - Payload bytes = JSON of `{action: "attack"}` (accum after
     on_select). Verify with `json.Unmarshal` and field-equality
     check.

These two integration tests cover the chunk 4 mechanism end-to-end.

#### 7.2.5 Validation gate

```
go run ./tools/cataloggen/ -check
go test ./game/interaction/...
go test ./game/...
go test -tags guard ./internal/importguard/...
go run ./tools/devctl testall
```

All must pass.

### 7.3 Chunk 4 implementation notes

#### 7.3.1 Interpreter VM access discipline

The interpreter VM is private to the Interpreter struct. Schema
modules are `require`d into it on first OpenFlow for that schema
(gopher-lua's require caches). Each ProcessInput re-uses the
loaded module's functions — no re-loading per call.

VM stack discipline: every entry to ProcessInput records
`baseTop := vm.GetTop()`; `defer vm.SetTop(baseTop)` ensures the
stack is restored on every return path. Mirrors Phase 5's
`EvaluateComponent` pattern.

#### 7.3.2 Cross-VM table copy reuse

Chunk 4 reuses Phase 5's existing internal helper
`game/defs.deepCopyLuaTable` by exporting it. Per D10's amendment,
additive read-only / utility helpers are permitted when they
prevent duplication.

**Exported signature (locked):**

```go
// game/defs/assemble.go (modified in chunk 4)
//
// DeepCopyLuaTable produces an independent copy of src on
// targetL. Source values may live on any LState (cross-VM safe);
// the result lives on targetL. Supports LString, LNumber, LBool,
// LNil, and LTable (recursive). Rejects LFunction, LUserData,
// channel values, and any source value with a metatable.
// Cycles are detected and rejected.
//
// The visited set is managed internally; callers do not pass it.
func DeepCopyLuaTable(targetL *lua.LState, src *lua.LTable) (*lua.LTable, error)
```

Implementation: chunk 4 wraps the existing internal
`deepCopyLuaTable(targetL, src, visited)` (which takes a visited
map) by allocating a fresh visited map and delegating. The
internal helper stays as-is; the exported wrapper is one new line.

**Metatable behavior reconciliation.** Phase 5 design said reject
metatables; the actual implementation may not currently check
this. Chunk 4 specifies that the exported `DeepCopyLuaTable`
**must** check and reject source tables with metatables (returns
`opDefsAssembleFailed` with a clear message). If the existing
internal helper doesn't yet check, chunk 4 adds the check there;
this is a small implementation tightening (still no behavioral
change to existing Phase 5 callers because authored content
doesn't use metatables — the safety net was always implied).

#### 7.3.3 Snapshot mechanism

`snapshotAccum` deep-copies the Accum LTable on the same VM
(both source and target are the interpreter VM). Same
`defs.DeepCopyLuaTable` helper (D62), with target == source VM.

`restoreAccum` walks the snapshot and overwrites entries on the
live accum. Implementation:

```go
func restoreAccum(target, source *lua.LTable) {
    // Clear existing entries on target.
    target.ForEach(func(k, v lua.LValue) {
        target.RawSet(k, lua.LNil)
    })
    // Copy snapshot entries onto target.
    source.ForEach(func(k, v lua.LValue) {
        target.RawSet(k, v)
    })
}
```

Note: source and target are same-VM tables (both on Interpreter.vm),
so the copy is shallow LValue copy (LTable values are references).
This is correct — the snapshot was already a deep-copy at snapshot
time; restoration just rewires top-level keys.

#### 7.3.4 Directive parsing as a strict whitelist

`process.go::parseDirective(retVal lua.LValue) (parsedDirective, error)`:
- Asserts retVal is a non-nil LTable; else error.
- Walks the table via ForEach.
- For each key: switch on the recognized key name; populate the
  corresponding parsed field; reject unknown keys with a clear
  message including the key name.
- Apply mutual-exclusion checks at the end.

Directive parser is small (one function, ~50 lines). Tests in
`outcome_test.go` cover each key + each conflict.

#### 7.3.5 Test interpreter helper

```go
// game/interaction/interpreter/testhelpers_test.go
//
// newTestInterpreter constructs an Interpreter and a *schema.Registry
// from the supplied source map. The same fake script reader backs
// both — one source-of-truth for tests. Used across open_test,
// process_test, outcome_test to avoid boilerplate.
//
// The map is keyed by asset key ("script.game.interaction.schemas.<name>").
// Caller defers ip.Close().
func newTestInterpreter(t *testing.T, sources map[string]string) (*Interpreter, *schema.Registry)
```

Mirrors chunk 1's fake-script-reader pattern. Tests that need
distinct script readers for registry vs. interpreter (e.g.,
`TestOpenFlow_SchemaModuleLoadFailed` per §7.2.3) call
`schema.Load` and `interpreter.New` directly with separate
readers rather than using this helper.

#### 7.3.6 Read-only proxy unwrap pattern

Per D62, `script.UnwrapReadOnly(t)` returns the shadow table
behind a `MakeReadOnly` proxy, or `t` itself if not a proxy. Two
discipline rules for the interpreter:

1. **Unwrap at every level when iterating.** `MakeReadOnly` is
   recursive: a proxy table contains proxy entries. Walking the
   shadow gets the entries, but each entry may itself be a proxy.
   The interpreter's option iterator unwraps each entry before
   reading its `key` / `label`:

   ```go
   func collectOptionKeys(L *lua.LState, options *lua.LTable) (map[string]struct{}, error) {
       options = script.UnwrapReadOnly(options)
       set := map[string]struct{}{}
       var iterErr error
       options.ForEach(func(idx, val lua.LValue) {
           if iterErr != nil { return }
           entry, ok := val.(*lua.LTable)
           if !ok { iterErr = ...; return }
           entry = script.UnwrapReadOnly(entry)
           keyVal := entry.RawGetString("key")
           keyStr, ok := keyVal.(lua.LString)
           if !ok { iterErr = ...; return }
           set[string(keyStr)] = struct{}{}
       })
       return set, iterErr
   }
   ```

2. **Don't unwrap before calling Lua functions.** `options_fn`,
   `on_select`, and other schema functions accept ctx (and other
   tables) as Lua values; they must remain proxy-wrapped so
   `__index` / `__newindex` enforce read-only on the Lua side.
   Unwrap only for Go-side iteration, never for re-passing to
   Lua.

#### 7.3.7 No HandleFrame integration

Per D11/D45: chunk 4 introduces no HandleFrame branching, no
per-mode flag wiring, no engine surface changes. The interpreter
is exercised exclusively through unit and integration tests.

The chunk that lands the engine/pipeline animator surface
alignment will also wire the interpreter into HandleFrame's input
routing (delegating to chunk 5's RouteInput).

---

## 8. Chunk 5 — Focus + Input Routing

**Status:** Draft, pre-implementation.
**Goal:** Land focus-path tracking on TreeManager + a routing
package that orchestrates `RouteInput` over the tree and the
interpreter. Resolve `focus_entry` on attach. Bubble directional
input through parent-owned navigation routes; do intra-node
spatial nav via `UIQueryService.Navigate`. Restore focus on
detach. No HandleFrame wiring (deferred per D11/D45).

### 8.1 Chunk 5 decisions (D64–D76)

#### D64. Package layout — `game/interaction/routing/`

```
game/interaction/routing/
  routing.go         — RoutingInput, action enum, free functions
  route_input.go     — RouteInput orchestration
  attach.go          — HandleAttach + focus_entry resolution
  detach.go          — HandleDetach + parent focus restoration
  nav.go             — directional bubbling, parent route lookup
  errors.go          — op constants
  routing_test.go
  attach_test.go
  detach_test.go
  nav_test.go
  testhelpers_test.go
```

Sibling of `schema/`, `tree/`, `interpreter/` under
`game/interaction/`. Diverges from design spec §12.3, which puts
`RouteInput` directly on `TreeManager`. The divergence is
necessary because:

- TreeManager lives in `game/interaction/tree/`.
- RouteInput needs to call `interpreter.Interpreter.ProcessInput`
  for select/confirm/cancel actions.
- `interpreter` already imports `tree` (chunk 4 D48); making
  `tree` import `interpreter` would create a circular dependency.

The routing package imports both `tree` and `interpreter` and
exposes free functions. TreeManager retains the focus-state
fields (D66) but the orchestration logic lives one layer above.

#### D65. Stateless free functions — no `Router` type

The routing package exposes free functions, not a `Router`
struct:

```go
package routing

func RouteInput(
    tm *tree.TreeManager,
    interp *interpreter.Interpreter,
    queries query.UIQueryService,
    screenUI core.UINode,
    input RoutingInput,
) (interpreter.FlowResult, error)

func HandleAttach(
    tm *tree.TreeManager,
    node *tree.FlowNode,
    queries query.UIQueryService,
    screenUI core.UINode,
) error

func HandleDetach(tm *tree.TreeManager, parentInstanceID string) error
```

Caller supplies all dependencies per call. Avoids Router
lifecycle questions and makes tests trivial to construct.

The TreeManager carries the focus-path state (D66); the routing
functions read and update it. State is in tree, behavior is in
routing.

#### D66. TreeManager focus state additions

Chunk 4 introduced `FlowState.FocusedNodeID` (per-node, populated
by routing). Chunk 5 also adds tree-level focus state on
`TreeManager`:

```go
// game/interaction/tree/manager.go (modified in chunk 5)
type TreeManager struct {
    // ... existing fields ...
    focusedInstanceID string  // input-owning node; "" = no focus
}

// game/interaction/tree/manager.go (new accessors in chunk 5)

// FocusedInstanceID returns the input-owning node's instance_id,
// or "" if no node currently owns input. The deepest interactive
// focusable node on the focus path owns input (per design spec
// §4.1); the path is implicit, derived by walking the named
// node's parent chain.
func (tm *TreeManager) FocusedInstanceID() string

// SetFocusedInstanceID sets the input-owning node. Routing
// updates this on attach/detach/navigation.
//
// id == ""        → clears focus (allowed; e.g., during input
//                   suppression or before initial focus).
// non-empty id    → must resolve via Lookup; else
//                   opTreeUnknownInstance. The resolved node must
//                   satisfy ALL of:
//                     • Schema.Profile == ProfileInteractive
//                       (else opTreeFocusedNotInteractive)
//                     • Schema.Focusable == true
//                       (else opTreeFocusedNotFocusable)
//                     • node.FlowState != nil — i.e.,
//                       interpreter.OpenFlow has been called
//                       (else opTreeFocusedNotOpen)
//
// Per design spec §4.1, only interactive focusable nodes can own
// input. Passive nodes can be on the focus path (as ancestors)
// but cannot be the focused (input-owning) node. The Focusable
// check catches authored interactive nodes that are explicitly
// non-focusable (focusable = false) — design spec §2.2 separates
// "interactive" from "focusable."
func (tm *TreeManager) SetFocusedInstanceID(id string) error

// FocusPath returns the chain from mode root to the focused
// node. Empty if no focus. Used by routing for bubbling.
func (tm *TreeManager) FocusPath() []*FlowNode

// RootSchemaID returns the root schema_id passed to
// NewTreeManager — the same string used as the mode-name in
// TranslateScope.Scope (chunk 3 D41) and as the prefix for
// emitted UINode IDs and placeholder IDs. Routing reads this for
// focus_entry = "named:<id>" resolution (D69) and any other
// place that needs the mode-name string.
func (tm *TreeManager) RootSchemaID() string
```

`SetFocusedInstanceID("")` clears focus (no input-owning node;
e.g., during input suppression or before initial focus is
established). Validation: non-empty id must exist in
`tm.nodes`.

`FocusPath()` walks the focused node's parent chain via
`Node.Parent` and returns root → focused. If focusedInstanceID
is "", returns empty slice.

These accessors are in tree package; routing reads and writes
through them.

#### D67. `RoutingInput` shape — extended action set

Routing's input type extends interpreter's `FlowInput.Action` enum
with directional actions:

```go
// game/interaction/routing/routing.go

type RoutingInput struct {
    Action    RoutingAction
    Selection *interpreter.SelectionData  // populated for ActionSelect; nil otherwise
}

type RoutingAction string

const (
    RoutingActionSelect   RoutingAction = "select"
    RoutingActionConfirm  RoutingAction = "confirm"
    RoutingActionCancel   RoutingAction = "cancel"
    RoutingActionNavLeft  RoutingAction = "nav_left"
    RoutingActionNavRight RoutingAction = "nav_right"
    RoutingActionNavUp    RoutingAction = "nav_up"
    RoutingActionNavDown  RoutingAction = "nav_down"
)
```

Mapping from RoutingAction to interpreter.InputAction:

| RoutingAction       | Handled by  | interpreter.InputAction |
|---------------------|-------------|-------------------------|
| `select`            | interpreter | `select`                |
| `confirm`           | interpreter | `confirm`               |
| `cancel`            | interpreter | `cancel`                |
| `nav_left/right/up/down` | routing | (not delegated)         |

Why two enums: interpreter's `FlowInput.Action` is the
schema-relevant input type (the union of actions a schema
function might be invoked for). Routing's `RoutingAction` is the
controller-input type (the union of actions a controller might
produce). Directional actions never reach interpreter; their
effect is to update focus state.

Selection is reused from interpreter (no duplicate type).

#### D68. `RouteInput` orchestration algorithm

```
RouteInput(tm, interp, queries, screenUI, input):
    // 1. Locate the input-owning node. If no focus, no-op.
    focusedID := tm.FocusedInstanceID()
    if focusedID == "":
        return zero FlowResult, nil  // no error; no input handled

    node, ok := tm.Lookup(focusedID)
    if !ok:
        return zero, opRoutingFocusedNodeMissing  // tree drift

    // 2. Dispatch by action.
    switch input.Action:
    case Select, Confirm, Cancel:
        // Delegate to interpreter; no routing-side side effects.
        flowInput := translateToInterpreterInput(input.Action, input.Selection)
        return interp.ProcessInput(node, flowInput)

    case NavLeft, NavRight, NavUp, NavDown:
        // Routing-side handling. No FlowResult for nav.
        return handleDirectional(tm, node, queries, screenUI, input.Action)
```

`handleDirectional` (in `nav.go`):

```
handleDirectional(tm, node, queries, screenUI, dir):
    // 1. Try intra-node spatial nav via UIQueryService.
    target := queries.Navigate(toNavDirection(dir))
    // Ownership-boundary check: the engine's spatial graph may
    // return a UINode in a sibling flow node's subtree. That's
    // an inter-node transition, not an intra-node move. Treat
    // out-of-subtree results as "no intra-node neighbor" and
    // fall through to bubble.
    instancePrefix := tm.RootSchemaID() + "/" + node.InstanceID + "/"
    if target != "" && strings.HasPrefix(string(target), instancePrefix):
        // Found a neighbor in this direction within the current
        // node's subtree. Update FocusedNodeID. node.FlowState
        // is guaranteed non-nil because tm.FocusedInstanceID()
        // points here, and SetFocusedInstanceID (D66) rejects
        // nodes whose FlowState is nil. So this write is safe
        // by invariant.
        node.FlowState.FocusedNodeID = target
        return zero FlowResult, nil
    // (target == "" OR target outside subtree → fall through)

    // 2. No intra-node neighbor (or out-of-subtree). Walk up the
    //    parent chain looking for an ancestor whose schema has a
    //    matching navigation route. `fromChild` is the immediate
    //    child of `cur` that the focused node descends from; the
    //    route's `From` field matches `fromChild.Schema.ID`.
    cur := node.Parent
    fromChild := node
    for cur != nil:
        if newFocusID := lookupNavRoute(cur, dir, fromChild); newFocusID != "":
            // Authored route found; attempt focus transfer.
            // SetFocusedInstanceID validates the target is interactive,
            // focusable, and open (D66). A route pointing to a
            // passive sibling, a non-focusable sibling, or an
            // interactive sibling whose flow hasn't been opened
            // FAILS the setter and chunk 5 propagates the error
            // wrapped as opRoutingNavTargetInvalid.
            //
            // Authoring guidance: navigation routes must target
            // siblings whose schemas can own input (interactive +
            // focusable) and whose flows are open at the time
            // navigation occurs. Chunk 1's battle_root navigation
            // references party_status which is currently passive;
            // that authored route is invalid by chunk 5's contract,
            // and chunk 5's tests do not exercise it. The route
            // becomes valid in a future chunk if/when party_status
            // is upgraded to interactive (analogous to chunk 4's
            // command_panel upgrade).
            if err := tm.SetFocusedInstanceID(newFocusID); err != nil:
                return zero FlowResult, wrap(opRoutingNavTargetInvalid, err)
            // Resolve focus_entry on the new owner per D69.
            newNode, _ := tm.Lookup(newFocusID)
            return resolveEntryAndReturn(tm, newNode, queries, screenUI)
        // Walk up; the next iteration's fromChild is the current cur.
        fromChild = cur
        cur = cur.Parent

    // 3. No matching route; bubble exhausted at mode root.
    return zero FlowResult, nil  // no-op
```

`lookupNavRoute(node, dir, fromChild)` — for the node's schema,
look for a navigation entry whose `From` matches `fromChild.Schema.ID`
and whose `Direction` matches `dir`. Return the resolved
instance_id of the target sibling (a child of `node` whose
schema_id matches the route's `To`), or "" if no match. See
§8.3.2 for the full algorithm.

Edge case: if the focused node is a top-level child of the mode
root, `cur` walks up to the mode root. The mode root has
navigation routes (per chunk 1 root-profile schema). The route's
`From` is the focused child's schema_id; the `To` is the sibling's
schema_id. Resolve `To` to its instance_id (via the parent's
Children list).

If the focused node is deeper (transient under a transient), the
walk continues upward. Most authored content has navigation
only on mode roots in chunks 1-5; deeper navigation routes are
authored content additions in later chunks.

Return value: `interpreter.FlowResult{}` (zero value) for nav
actions. Routing returns FlowResult only because the Select/
Confirm/Cancel path needs to. Nav actions don't produce a
FlowResult.

#### D69. `focus_entry` resolution rules

When a node becomes input-owning (via attach, navigation, or
HandleFrame initial focus), routing resolves its initial
`FocusedNodeID` per the schema's `focus_entry`:

| `focus_entry`         | Rule                                                                          |
|-----------------------|-------------------------------------------------------------------------------|
| `none`                | `FocusedNodeID = ""`. Node is not input-owning.                               |
| `first_focusable`     | Walk the node's emitted subtree in `screenUI` left-to-right depth-first; pick the first UINode with `Focus.Focusable == true` (per [engine/ui/core/types.go:248](engine/ui/core/types.go#L248)'s `FocusSpec`). If none found (subtree not yet rendered or no focusables), set `FocusedNodeID = ""` (deferred — will retry on next render). |
| `remember`            | If `FlowState.FocusedNodeID` is non-empty AND its UINode still exists in `screenUI`, keep it. Else fall back to `first_focusable`. |
| `named:<id>`          | Resolve the named local ID to a full UINode ID: `<tm.RootSchemaID()>/<instance_id>/<named>` (D66 `RootSchemaID()` accessor). If that UINode exists in `screenUI`, use it; else `opRoutingNamedFocusMissing`. |

The walk for `first_focusable` traverses `screenUI`'s tree
recursively; a UINode subtree is "node X's emitted subtree" if
the UINode ID is prefixed by `<rootSchemaID>/<X.InstanceID>/`.

Implementation note: chunk 5 walks `screenUI` directly (per Otto's
greenlight on call 5: avoids adding a `FirstFocusable` method to
`UIQueryService`). The walk is in `attach.go::resolveFocusEntry`
or similar.

`Focus.Focusable == true` on a UINode is set by component
authoring (for selectable nodes). The bridge preserves this flag
from the Lua primitive tree's `FocusSpec`.

#### D70. Focus transfer on Attach — `HandleAttach`

When a transient node is attached (via `tree.Attach` /
`tree.AttachTo`), routing handles focus transfer:

```go
func HandleAttach(
    tm *tree.TreeManager,
    node *tree.FlowNode,
    queries query.UIQueryService,
    screenUI core.UINode,
) error
```

The contract:

1. **The caller saves** `tm.FocusedInstanceID()` BEFORE calling
   `tree.Attach` if it wants to restore focus on later detach.
   `HandleAttach` does NOT save this for the caller — there's
   nowhere on the API to return it. The caller is responsible
   for managing the saved-parent-focus chain.
2. `HandleAttach` inspects `node.Schema.FocusEntry.Kind` first.
   If `FocusEntryNone`: **no focus transfer**. `tm.FocusedInstanceID()`
   stays as it was; the previously focused node remains
   input-owning. Return nil.
3. Else (`first_focusable`, `remember`, or `named:<id>`):
   a. Pre-validate at the routing layer with routing-specific
      ops for clearer diagnostics. Three conditions, mirroring
      D66's tree-level checks:
        - `node.Schema.Profile != ProfileInteractive` →
          `opRoutingFocusedNotInteractive`.
        - `node.Schema.Focusable == false` →
          `opRoutingFocusedNotFocusable`.
        - `node.FlowState == nil` (`OpenFlow` not called
          between `tree.Attach` and `routing.HandleAttach`) →
          `opRoutingFocusedNotOpen`.
      The routing-level pre-check is a usability nicety; the
      tree-level `SetFocusedInstanceID` (D66) is the
      authoritative defensive guard against the same three
      conditions and would otherwise return
      `opTreeFocusedNotInteractive` / `opTreeFocusedNotFocusable` /
      `opTreeFocusedNotOpen`. Both layers' ops are listed in
      `errors.go` of their respective packages.
   b. `tm.SetFocusedInstanceID(node.InstanceID)`. Per (a) this
      should always succeed; if it doesn't (defensive — e.g.,
      tree mutated between pre-check and call), the error is
      wrapped and returned as-is.
   c. Resolve `node.FlowState.FocusedNodeID` per D69's rules
      using the supplied `screenUI`.

Caller order:
1. (Caller) `saved := tm.FocusedInstanceID()` — caller's bookkeeping
2. `tree.Attach(...)` (or `AttachTo`)
3. `interpreter.OpenFlow(node)` — required if schema is interactive
4. `routing.HandleAttach(tm, node, queries, screenUI)`
5. (Later, on detach) `routing.HandleDetach(tm, saved)` — caller passes
   its saved value

The Attach + OpenFlow + HandleAttach trio is the "attach a
transient flow" sequence. Higher-level integration (chunk 6 or
its successor) will likely wrap this into a single helper.

`HandleAttach` accepts `screenUI` from the prior frame. The new
node's subtree won't be in that screenUI yet (it just attached);
`first_focusable` will see the new node as missing and set
FocusedNodeID = "" (deferred). Next frame's render adds the
subtree; chunks 6+ wire a "retry initial focus" pass that picks
it up.

For passive (non-interactive) attaches: schema's `focus_entry`
must be `none` (chunk 1 D14 rules: passive profiles have no
interactive fields and `focus_entry` defaults to `none`). The
step 2 short-circuit applies; no focus transfer.

Errors:
- `node == nil` → `opRoutingNilNode`.
- `focus_entry != "none"` AND `node.FlowState == nil` (not opened) →
  `opRoutingFocusedNotOpen`. The caller forgot to call
  `interpreter.OpenFlow` first.
- Schema's `focus_entry` is `named:<id>` AND the node's subtree
  IS in `screenUI` AND the named UINode is not in that subtree →
  `opRoutingNamedFocusMissing`. If the subtree is not in screenUI
  at all (just attached, not yet rendered), treat as deferred —
  same as first_focusable.

#### D71. Focus restoration on Detach — `HandleDetach`

When a transient node is detached (via `tree.Detach`), routing
restores focus:

```go
func HandleDetach(tm *tree.TreeManager, parentInstanceID string) error
```

The caller passes `parentInstanceID` — the ID of the parent that
will reclaim focus. Typically this is the caller's saved value
from before the attach.

Implementation:

1. If `parentInstanceID == ""`: clear focus
   (`tm.SetFocusedInstanceID("")`); return nil.
2. Else: try `tm.SetFocusedInstanceID(parentInstanceID)`.
   - **If it succeeds**: parent's `FlowState.FocusedNodeID` is
     left as-is (per design spec §2.6: FocusedNodeID persists
     across frames). Return nil.
   - **If it fails because parent doesn't exist** (`opTreeUnknownInstance`):
     forgivingly clear focus. Return nil. (Caller's saved
     reference may be stale; chunk 5 absorbs this.)
   - **If it fails for any other reason** (`opTreeFocusedNotInteractive`
     or `opTreeFocusedNotOpen`): the saved parent IS still in
     the tree but is not a valid focus owner (e.g., the parent
     was a passive node, or its interactive flow was closed
     between save and restore). Clear focus AND return the
     wrapped error as `opRoutingDetachInvalidParent`. Caller
     decides whether to treat as fatal.

Note: chunk 5's HandleDetach does NOT walk the tree to find a
"new natural focus owner" — it uses what the caller supplies.
This makes the contract simpler: caller decides who reclaims
focus; routing applies.

For the typical case (attach pause_menu → detach pause_menu →
focus returns to whatever was focused before), the caller has
saved `tm.FocusedInstanceID()` BEFORE the attach (per D70) and
passes that here.

The forgiving-on-unknown rule lets caller bookkeeping survive
transient-under-transient detach chains where intermediate
parents may also have been detached. The strict-on-invalid-but-
present rule surfaces real bugs (caller pointing at a passive
sibling, etc.).

#### D72. Inter-node navigation — parent-owned routes

Per design spec §4.4: edge directional input bubbles to parent
node, which transfers focus per declared navigation routes.

Each schema's `Navigation` field (chunk 1 D-list, root profile
only) is an array of `NavigationRoute{From, Direction, To}`.
These are sibling-navigation rules.

For chunk 5 routing, "navigation routes" are looked up on the
**ancestor** chain — the input-owning node's parent, then
grandparent, etc., until a matching route is found or the mode
root is reached.

The match condition: a route's `From` equals the schema_id of
the child of `cur` that the focused node is descended from
(typically the focused node itself, if it's a direct child of
`cur`); the `Direction` equals the input direction. The route's
`To` schema_id resolves to the corresponding child instance_id
in `cur.Children`.

For chunks 1-5's authored content, navigation routes only exist
on mode roots; the ancestor walk reaches the mode root and finds
the route there.

If `cur` has multiple children with the same schema_id (per
chunk 2 D26 sibling-index), the route's `To` is ambiguous —
chunk 5 picks the first matching child by declaration order.
Authoring guidance: avoid this case. (Chunk 5 spec doesn't add
explicit indexing to navigation routes; future chunks may.)

#### D73. Intra-node spatial nav via `UIQueryService.Navigate`

Per design spec §4.3: D-pad input within a node's selectable
items is resolved by the engine's compiled focus graph via
`UIQueryService.Navigate`.

Verified API at [engine/ui/query/query.go:18](engine/ui/query/query.go#L18):

```go
type UIQueryService interface {
    Focused() core.NodeID
    HitTest(x, y int) core.NodeID
    Navigate(direction NavigationDirection) core.NodeID
}
```

`Navigate` uses the service's internal "currently focused" state
(seeded from the prior frame's compiled UI) and returns the
neighbor in the requested direction. Empty result means no
neighbor in that direction.

Mapping from `RoutingAction` to `NavigationDirection`:
- `nav_up` → `query.NavUp`
- `nav_down` → `query.NavDown`
- `nav_left` → `query.NavLeft`
- `nav_right` → `query.NavRight`

Routing flow for a directional action (per D68):
1. Call `queries.Navigate(direction)`.
2. If the result is non-empty AND its NodeID is prefixed by
   `<tm.RootSchemaID()>/<focusedNode.InstanceID>/`: the target is
   intra-node. Update `node.FlowState.FocusedNodeID = target`. Done.
3. If the result is empty OR outside the focused node's subtree
   (the engine's spatial graph proposed crossing into a sibling
   flow node's subtree, which violates the inter-node routing
   model): bubble to parent navigation routes (D72).

**Two distinct focus concepts** (do not conflate):

| Value                              | Type           | Owner                               | Meaning                                        |
|------------------------------------|----------------|-------------------------------------|------------------------------------------------|
| `tm.FocusedInstanceID()`           | `string`       | TreeManager (chunk 5 routing-level) | Which **flow node** owns input                 |
| `node.FlowState.FocusedNodeID`     | `core.NodeID`  | Per FlowNode (chunk 4 + 5)          | Which **UINode** within that flow node's subtree is focused |
| `queries.Focused()`                | `core.NodeID`  | Engine (prior frame's compiled UI)  | Which **UINode** the engine considers focused |
| `FrameOutput.Focused`              | `core.NodeID`  | Game writes (HandleFrame integration) | Which **UINode** to seed next frame's compiled UI |

The engine boundary (`FrameOutput.Focused` and
`UIQueryService.Focused()`) speaks **UINode IDs**, not flow
instance_ids. Verified at
[engine/pipeline/game_frame_handler.go:54](engine/pipeline/game_frame_handler.go#L54)
— `FrameOutput.Focused core.NodeID`.

**Coupling pattern** (when HandleFrame integration lands, not
chunk 5):

1. Game has a focused flow node — `tm.FocusedInstanceID()` —
   and that node has a focused UINode under its subtree —
   `focusedNode.FlowState.FocusedNodeID`.
2. Game writes `FrameOutput.Focused = focusedNode.FlowState.FocusedNodeID`
   (the UINode ID, not the flow instance_id).
3. Engine compiles next frame's UI seeded from that focus.
4. Frame N+1: `queries.Focused()` returns the same UINode ID.
5. The expected invariant: `queries.Focused()` matches the
   currently focused flow node's `FlowState.FocusedNodeID`.
   `tm.FocusedInstanceID()` is internal routing state with no
   direct engine counterpart.

`Navigate` uses the query service's internal "currently focused"
state (a UINode ID) and returns the spatial neighbor (also a
UINode ID). Chunk 5 routing reads the returned UINode and:

- Verifies the result is within the current flow node's subtree
  (per D68 ownership-boundary check); if not, treats as no
  intra-node neighbor and bubbles to parent navigation routes.
- If within: writes the result to
  `focusedNode.FlowState.FocusedNodeID`.

Chunk 5 does NOT actively reconcile `tm.FocusedInstanceID()`
with anything engine-side, because there is no engine-side
counterpart. The HandleFrame integration chunk wires the
UINode-side coupling (point 2 above).

Tests in chunk 5 use a synthetic `query.UIQueryService` that's
configured with explicit Focus/Navigate behavior per test case.

#### D74. `Selection.Key` resolution from FocusedNodeID — deferred

When a `select` input arrives, `interpreter.ProcessInput` needs
`Selection{Key, Label}`. The natural source is the currently-
focused selectable UINode's identity. But chunk 5 has no
mechanism to map UINode ID → option key.

Three resolution paths exist:
- **UINode tag** — bridge attaches an `OptionKey` field to
  selectable UINodes; routing reads it.
- **ID convention** — selectable UINodes have local IDs like
  `option_<key>`; routing parses.
- **Renderer side-table** — Render emits a map alongside the
  UINode tree.

Chunk 5 does NOT lock this. Chunk 5's `RouteInput`:
- Takes `RoutingInput.Selection` from the caller for select
  actions.
- Does not read it from FocusedNodeID.
- If `Action == Select` and `Selection == nil` (or empty Key),
  routing passes through to `interpreter.ProcessInput`, which
  rejects with `opInterpSelectMissingSelection` per chunk 4 D53.
  Routing does NOT add its own validation for this case;
  interpreter is the canonical guard.

Tests pass `Selection` explicitly for the success path; the
nil-Selection-falls-through behavior is tested via the existing
chunk 4 `TestProcessInput_SelectMissingSelection`. Chunk 5 may
add a thin `TestRouteInput_Select_NilSelection_PassesThroughToInterpreter`
to confirm the routing layer doesn't intercept.

The integration (HandleFrame or its successor) will resolve from
FocusedNodeID and call RouteInput with explicit Selection.

This deferral is added to §9. The chunk that wires the live
controller-to-router path locks one of the three resolution
paths.

#### D75. Layer guard for `game/interaction/routing`

```go
{
    ImporterBase: modulePath + "/game/interaction/routing",
    ForbiddenBase: []string{
        modulePath + "/engine/runtime",
        modulePath + "/engine/pipeline",
        modulePath + "/engine/sim",
        modulePath + "/engine/world",
        modulePath + "/engine/presentation",
        modulePath + "/engine/overlay",
        modulePath + "/engine/text",
        modulePath + "/engine/display",
        modulePath + "/engine/animation",
        modulePath + "/engine/session",
        modulePath + "/engine/command",
        modulePath + "/engine/errorsink",
        modulePath + "/engine/clock",
        modulePath + "/engine/ds",
        modulePath + "/engine/fs",
        modulePath + "/engine/ecs",
        modulePath + "/engine/ui/anim",
        modulePath + "/engine/ui/style",
        modulePath + "/engine/script",
        modulePath + "/game/menu",
        modulePath + "/game/flow",
        modulePath + "/game/ui",
        modulePath + "/game/defs",
        modulePath + "/game/mode",
        modulePath + "/game/presentation",
        modulePath + "/game/proposals",
        modulePath + "/game/snapshot",
        modulePath + "/game/uidef",
        modulePath + "/game/worldstreaming",
        modulePath + "/render",
        modulePath + "/platform",
    },
    ExactImport: []string{
        modulePath + "/game",
    },
    Rule:        "layers.md: game/interaction/routing may import engine/coretypes, engine/ui/core, engine/ui/query, game/interaction/schema, game/interaction/tree, game/interaction/interpreter, failures, and gopher-lua only",
    Remediation: "routing orchestrates tree + interpreter; UI primitive types from engine/ui/core + engine/ui/query for spatial nav; no other UI/render/anim concerns",
},
```

Allowed imports (by absence + narrowness):
- `engine/coretypes`, `engine/ui/core`, `engine/ui/query`
- `game/interaction/schema`, `game/interaction/tree`,
  `game/interaction/interpreter`
- `failures`, `gopher-lua`

Forbidden: `engine/ui/anim`, `engine/ui/style`, `game/ui/*` —
routing has no rendering or animation concerns. Forbidden:
`engine/script`, `engine/command` — no Lua execution or command
queue work. Forbidden: `game/mode`, `game/defs` — routing reads
schemas via the schema registry pointer on `tree`, doesn't touch
mode or defs directly.

`engine/ui/query` is newly allowed for chunk 5's routing
package. Tree's layer guard (chunk 3 D36) does NOT permit
`engine/ui/query` — chunk 3 only added `engine/ui/{core,anim,style}`.
Tree doesn't need query because it doesn't do spatial nav;
routing does.

Note: routing imports `game/interaction/interpreter`, which
imports `engine/pipeline`. The transitive dep on
`engine/pipeline` is acceptable because routing returns
`interpreter.FlowResult` whose underlying types include
`pipeline.HostRequest`. The layer guard's prefix-match on
`engine/pipeline` ForbiddenBase blocks DIRECT imports from
routing; transitive types are not blocked.

#### D76. Test layout

Tests split:

- **Unit tests** in `game/interaction/routing/` use synthetic
  schema fixtures, a synthetic `query.UIQueryService`, and an
  in-test-built `screenUI`. They exercise routing logic without
  ECS, defs, or real components. They DO use the interpreter
  package (real Interpreter with synthetic schema fixtures) for
  the select/confirm/cancel delegation tests.
- **Integration test** in `game/tree_integration_test.go`
  (extending what chunks 2-4 grew) exercises routing against the
  real chunk-1+2+4 authored schemas with real interpreter +
  derivation. Two tests:
  - `TestRouting_PauseMenu_NavigateAndCancel` — pause_menu
    attach + nav + cancel/back; uses synthetic screenUI per
    Fix 4 above.
  - `TestRouting_CommandPanel_Select_DelegatesToInterpreter` —
    command_panel select-action delegating to interpreter
    end-to-end, producing a battle_action command.

A test-helper `query.UIQueryService` lives in
`testhelpers_test.go`:

```go
type fakeQueries struct {
    focused   core.NodeID
    neighbors map[core.NodeID]map[query.NavigationDirection]core.NodeID
}
```

Tests configure neighbors and focus per-case. The synthetic
queries don't simulate the engine's compiled focus graph; they
just return what the test sets up.

### 8.2 Chunk 5 acceptance

#### 8.2.1 New files

```
game/interaction/routing/
  routing.go         — RoutingInput, action enum
  route_input.go     — RouteInput orchestration
  attach.go          — HandleAttach + focus_entry resolution
  detach.go          — HandleDetach + parent focus restoration
  nav.go             — directional bubbling + parent route lookup
  errors.go          — op constants
  routing_test.go
  attach_test.go
  detach_test.go
  nav_test.go
  testhelpers_test.go  — fakeQueries, screen-UI builders
```

No new authored Lua. Existing chunk 1 + chunk 2 + chunk 4
schemas suffice for tests.

#### 8.2.2 Modified files

- `game/interaction/tree/manager.go` — add `focusedInstanceID`
  field to TreeManager, plus `FocusedInstanceID()`,
  `SetFocusedInstanceID()`, `FocusPath()`, `RootSchemaID()`
  accessors.
- `game/interaction/tree/errors.go` — add `opTreeFocusedNotInteractive`,
  `opTreeFocusedNotFocusable`, `opTreeFocusedNotOpen` op
  constants per D66.
- `game/interaction/tree/manager_test.go` — tests for the new
  accessors (validation: empty id clears focus; non-empty must
  resolve via Lookup, must be interactive + focusable + open).
- `internal/importguard/import_guard_test.go` — add
  `game/interaction/routing` rule (D75).
- `game/tree_integration_test.go` — extended with routing
  scenarios.

No HandleFrame integration files modified. Per D45, that is
deferred to a later chunk.

#### 8.2.3 Test matrix

`routing_test.go` (input dispatch):
- `TestRouteInput_NoFocus_NoOp` — `tm.FocusedInstanceID() == ""`;
  RouteInput returns zero result, no error.
- `TestRouteInput_FocusedNodeMissing` — focusedInstanceID set to
  an id not in `tm.nodes` → `opRoutingFocusedNodeMissing`.
- `TestRouteInput_Select_DelegatesToInterpreter` — select action
  reaches interpreter's ProcessInput with the expected FlowInput.
- `TestRouteInput_Select_NilSelection_PassesThroughToInterpreter` —
  routing does not intercept; interpreter raises
  `opInterpSelectMissingSelection` per chunk 4 D53.
- `TestRouteInput_Confirm_DelegatesToInterpreter`.
- `TestRouteInput_Cancel_DelegatesToInterpreter`.
- `TestRouteInput_NavLeft_NotDelegatedToInterpreter` — nav action
  does not call interpreter.

`nav_test.go` (directional handling):
- `TestHandleDirectional_IntraNodeNeighborFound` — synthetic
  queries returns a neighbor whose NodeID is within the focused
  node's subtree prefix; assert `node.FlowState.FocusedNodeID`
  updated to that neighbor; tree's `focusedInstanceID` unchanged.
- `TestHandleDirectional_QueryReturnsOutOfSubtree_BubblesToParent` —
  queries returns a non-empty NodeID that is OUTSIDE the focused
  node's `<rootSchemaID>/<instance_id>/` prefix (engine proposed
  crossing flow-node boundaries). Routing treats this as no
  intra-node neighbor; bubbles. Assert `node.FlowState.FocusedNodeID`
  unchanged; bubble outcome depends on parent route.
- `TestHandleDirectional_NoNeighbor_BubblesToParent` — queries
  returns empty; parent has no matching route; result no-op.
- `TestHandleDirectional_NoNeighbor_ParentRouteMatches` — queries
  empty; parent navigation route matches a sibling that is
  interactive + focusable + open; assert tree's `focusedInstanceID`
  updated to the route's `To`.
- `TestHandleDirectional_ParentRouteTargetPassive_ReturnsError` —
  queries empty; parent route's `To` is a passive sibling
  (e.g., chunk-1 battle_root's `to = "party_status"`); routing
  returns wrapped `opRoutingNavTargetInvalid` (around
  `opTreeFocusedNotInteractive`).
- `TestHandleDirectional_ParentRouteTargetNotOpen_ReturnsError` —
  route target is interactive but `interpreter.OpenFlow` was
  not called for it; routing returns wrapped
  `opRoutingNavTargetInvalid` (around `opTreeFocusedNotOpen`).
- `TestHandleDirectional_ParentRouteTargetNonFocusable_ReturnsError` —
  route target is interactive + open but
  `Schema.Focusable == false`; routing returns wrapped
  `opRoutingNavTargetInvalid` (around `opTreeFocusedNotFocusable`).
- `TestHandleDirectional_BubbleStopsAtModeRoot` — no parent in
  the chain has a matching route; bubble exhausts; no-op.
- `TestHandleDirectional_DuplicateChildSchemas_FirstMatched` —
  parent has two children with schema X; route `To = X`; first
  declared child receives focus.

`attach_test.go` (HandleAttach + focus_entry):
- `TestHandleAttach_FocusEntryNone_NoTransfer` — schema with
  `focus_entry = "none"`; tree's focusedInstanceID unchanged.
- `TestHandleAttach_FocusEntryFirstFocusable_FoundInScreenUI` —
  screenUI contains the new node's subtree with at least one
  `Focus.Focusable == true` UINode; assert node.FlowState.FocusedNodeID
  matches that UINode's ID.
- `TestHandleAttach_FocusEntryFirstFocusable_NoSubtreeYet` —
  screenUI does NOT contain the new node (just attached;
  not yet rendered); assert FocusedNodeID = "" (deferred);
  no error.
- `TestHandleAttach_FocusEntryRemember_HasPriorAndStillExists` —
  FlowState.FocusedNodeID was set to a UINode; that UINode is
  in screenUI; assert FocusedNodeID stays the same.
- `TestHandleAttach_FocusEntryRemember_PriorMissingFallsBackToFirst` —
  prior FocusedNodeID's UINode is NOT in screenUI; assert fallback
  to first_focusable.
- `TestHandleAttach_FocusEntryNamed_ResolvedAndExists` — schema
  `focus_entry = "named:option_quit"`; constructed UINode ID
  exists in screenUI; assert FocusedNodeID matches.
- `TestHandleAttach_FocusEntryNamed_MissingInRenderedSubtree` —
  named ID not in screenUI but subtree IS rendered →
  `opRoutingNamedFocusMissing`.
- `TestHandleAttach_FocusEntryNamed_SubtreeNotYetRendered` —
  named ID not in screenUI AND subtree absent → deferred (no
  error; FocusedNodeID = "").
- `TestHandleAttach_FocusEntryNoneSkipsTransfer` — schema with
  `focus_entry = "none"`; tree's `focusedInstanceID` is unchanged
  (previously focused node remains input-owning); no error even
  if the new node's FlowState is nil.
- `TestHandleAttach_NotOpenRejected` — interactive schema with
  `focus_entry != "none"`, but `interpreter.OpenFlow` was NOT
  called; HandleAttach returns `opRoutingFocusedNotOpen`. The
  caller's bookkeeping order (Attach → OpenFlow → HandleAttach)
  is required.
- `TestHandleAttach_PreservesParentFocusViaCallerBookkeeping` —
  caller saves `tm.FocusedInstanceID()` BEFORE `tree.Attach`
  (per D70 contract), then runs Attach + OpenFlow + HandleAttach,
  then verifies it can pass that saved value to HandleDetach
  later. (HandleAttach itself does not save anything for the
  caller.)
- `TestHandleAttach_NilNode` → `opRoutingNilNode`.

`detach_test.go` (HandleDetach + restoration):
- `TestHandleDetach_RestoresParentFocus` — HandleDetach with
  the parent's instance_id (interactive + open); tree's
  focusedInstanceID becomes that id; returns nil.
- `TestHandleDetach_EmptyParent_ClearsFocus` —
  parentInstanceID = ""; tree's focusedInstanceID becomes "";
  returns nil.
- `TestHandleDetach_UnknownParent_ClearsFocusForgivingly` —
  parentInstanceID points to a node no longer in tm.nodes;
  focus clears; **returns nil** (forgiving on stale references).
- `TestHandleDetach_PassiveParent_ClearsAndErrors` —
  parentInstanceID points to a passive node (still in tree);
  focus clears AND returns `opRoutingDetachInvalidParent`
  wrapping `opTreeFocusedNotInteractive`.
- `TestHandleDetach_NotOpenParent_ClearsAndErrors` —
  parentInstanceID points to an interactive node whose flow
  was closed between save and restore; focus clears AND
  returns `opRoutingDetachInvalidParent` wrapping
  `opTreeFocusedNotOpen`.
- `TestHandleDetach_PreservesParentNodeFocusedNodeID` — the
  parent's `FlowState.FocusedNodeID` is preserved across detach
  (not cleared).

TreeManager accessor tests (in `manager_test.go`, package
`tree`). **These tests do NOT import `game/interaction/interpreter`**
because `interpreter` already imports `tree` (chunk 4 D48);
adding the reverse direction would create an import cycle.
Instead, tests that need `node.FlowState != nil` seed the field
directly:

```go
// In tree's manager_test.go (package tree), NOT in interpreter.
// Inside package tree, FlowState is referenced unqualified.
node.FlowState = &FlowState{
    CurrentStep:   "main",
    Accum:         someTestLuaTable,
    FocusedNodeID: "",
}
```

Routing-package and integration tests use `interpreter.OpenFlow`
for the real path because they're allowed to import interpreter.

Cases:

- `TestSetFocusedInstanceID_EmptyClears`.
- `TestSetFocusedInstanceID_UnknownIDRejected` →
  `opTreeUnknownInstance`.
- `TestSetFocusedInstanceID_RootRejected` →
  `opTreeFocusedNotInteractive`. (Mode root has root profile,
  not interactive; locks the "root cannot own input" invariant.)
- `TestSetFocusedInstanceID_PassiveNodeRejected` →
  `opTreeFocusedNotInteractive`. (Persistent passive
  party_status; FlowState always nil for passive nodes.)
- `TestSetFocusedInstanceID_InteractiveNonFocusableRejected` →
  `opTreeFocusedNotFocusable`. (Synthetic interactive schema
  with authored `focusable = false`.)
- `TestSetFocusedInstanceID_InteractiveNotOpenRejected` →
  `opTreeFocusedNotOpen`. (Interactive + focusable node;
  `node.FlowState == nil`.)
- `TestSetFocusedInstanceID_InteractiveAndOpenAccepted` —
  same node with `node.FlowState` seeded directly to a non-nil
  `&FlowState{...}`; SetFocusedInstanceID succeeds.
- `TestFocusPath_NoFocus_Empty`.
- `TestFocusPath_SingleInteractiveChild` — focused id is the
  one interactive child of the mode root; FocusPath returns
  [mode_root, child]. (The mode root cannot itself be focused
  per D66 — root profile, no FlowState — but it is on the path
  as the focused node's parent.)
- `TestFocusPath_DeepFocus_RootToFocused` — focused id is a
  transient under a transient under a persistent interactive;
  FocusPath returns 4 nodes from root.
- `TestRootSchemaID_ReturnsConstructorArg`.

#### 8.2.4 Integration proof

`game/tree_integration_test.go` extended:

`TestRouting_PauseMenu_NavigateAndCancel`:

1. Setup as in chunks 2-4: registries, ECS, DerivationVM,
   ComponentVM, TreeManager built from `battle_root`,
   Interpreter constructed.
2. **Construct a synthetic `screenUI`** representing the
   pause_menu's expected rendered subtree. Pause_menu does not
   yet have a flow-node component authored (chunk 5 doesn't
   author components; chunks 6+ may), so chunk 3's placeholder
   path would render only an empty Panel — too sparse for
   first_focusable. The synthetic screenUI has the placeholder
   Panel for pause_menu PLUS test-fixture children with
   `Focus.Focusable = true` to exercise the routing logic.
   The construction is in `testhelpers_test.go::buildSyntheticScreenUI`.
3. **Establish a real focused parent** before any attach:
   a. Look up `commandPanelNode := tm.Lookup("battle_root.command_panel")`.
   b. `interp.OpenFlow(commandPanelNode)`.
   c. `tm.SetFocusedInstanceID(commandPanelNode.InstanceID)` —
      verify no error.
   d. **Save** `savedParentID := tm.FocusedInstanceID()` (caller-side
      bookkeeping per D70; should equal
      `"battle_root.command_panel"`).
   This guarantees the test exercises real focus restoration on
   detach, not the empty-savedParentID case.
4. `tree.Attach("pause_menu", tree.OpenContext{})`.
5. `interpreter.OpenFlow(pauseMenuNode)`.
6. (No second render call. The synthetic screenUI from step 2
   is what routing inspects.)
7. `routing.HandleAttach(tm, pauseMenuNode, fakeQueries{}, screenUI)`.
   Assert `tm.FocusedInstanceID()` is the pause_menu instance.
   Assert `pauseMenuNode.FlowState.FocusedNodeID` is the first
   focusable in the synthetic subtree (per first_focusable rule;
   pause_menu's chunk 1 schema has `focus_entry = "first_focusable"`).
8. Configure fakeQueries to return a neighbor for NavDown.
   `routing.RouteInput(tm, interp, fakeQueries, screenUI,
     RoutingInput{Action: NavDown})`. Assert FocusedNodeID
   updated.
9. `RoutingInput{Action: Cancel}`. Pause_menu's main step has
   `back = "cancel"`. Per chunk 4: triggers schema's on_cancel
   if defined (pause_menu doesn't define one); FlowResult.Cancelled
   is true.
10. `routing.HandleDetach(tm, savedParentID)`. Assert no error;
    `tm.FocusedInstanceID()` equals
    `"battle_root.command_panel"` (the saved parent).

`TestRouting_CommandPanel_Select_DelegatesToInterpreter`:

1. Similar setup.
2. Configure ECS world's view_state for command_panel (test-fixture
   derivation supplying `available_actions = [{key="attack",label="Attack"}]`).
3. **Open the flow before setting focus**:
   `interpreter.OpenFlow(commandPanelNode)`. Without OpenFlow,
   `SetFocusedInstanceID` rejects with `opTreeFocusedNotOpen` and
   `ProcessInput` rejects with `opInterpNotOpen`.
4. `tm.SetFocusedInstanceID("battle_root.command_panel")`.
5. `routing.RouteInput(tm, interp, fakeQueries, screenUI,
     RoutingInput{Action: Select, Selection: &SelectionData{Key: "attack"}})`.
6. Assert FlowResult.Completed=true; FlowResult.Outcome.Commands
   contains the battle_action command (per chunk 4 D61
   `command_panel`'s on_complete).

These two integration tests exercise the full chunks 2-5
pipeline against authored content. The synthetic screenUI in test
1 is the chunk 5 pattern for testing routing without depending
on a full Render output that requires components chunk 5 doesn't
author.

#### 8.2.5 Validation gate

```
go run ./tools/cataloggen/ -check
go test ./game/interaction/...
go test ./game/...
go test -tags guard ./internal/importguard/...
go run ./tools/devctl testall
```

All must pass.

### 8.3 Chunk 5 implementation notes

#### 8.3.1 Walking screenUI for first_focusable

```go
// game/interaction/routing/attach.go (illustrative)

func findFirstFocusableInSubtree(screenUI core.UINode, instancePrefix string) core.NodeID {
    var found core.NodeID
    walkUINode(screenUI, func(n *core.UINode) bool {
        if !strings.HasPrefix(string(n.ID), instancePrefix) {
            return true  // continue walk into siblings; this branch isn't ours
        }
        if n.Focus.Focusable {
            found = n.ID
            return false  // stop walk
        }
        return true  // descend
    })
    return found
}

func walkUINode(n core.UINode, visit func(*core.UINode) bool) bool {
    if !visit(&n) { return false }
    for _, c := range n.Children {
        if !walkUINode(c, visit) { return false }
    }
    return true
}
```

`instancePrefix` for a flow node is
`<tm.RootSchemaID()>/<node.InstanceID>/`. The walk descends only
branches whose ID starts with this prefix.

`UINode.Focus.Focusable` is verified at
[engine/ui/core/types.go:248](engine/ui/core/types.go#L248) —
`FocusSpec{Focusable, Zone, Default}`. Chunk 3's bridge
translation preserves this from authored components. No
implementer-discovery item.

#### 8.3.2 Navigation route lookup

The schema's `Navigation []NavigationRoute` field (chunk 1 D-list)
is on root-profile schemas only. For chunk 5 routing, the lookup
treats a non-root schema's missing Navigation as empty list
(no matches).

```go
func lookupNavRoute(node *tree.FlowNode, dir RoutingAction, fromChild *tree.FlowNode) (string, bool) {
    if node.Schema.Profile != schema.ProfileRoot:
        return "", false
    routes := node.Schema.Root.Navigation
    fromSchemaID := fromChild.Schema.ID
    direction := mapDir(dir)
    for _, r := range routes:
        if r.From == fromSchemaID && r.Direction == direction:
            // Resolve r.To to instance_id by finding the matching child.
            for _, child := range node.Children:
                if child.Schema.ID == r.To:
                    return child.InstanceID, true
    return "", false
}
```

`fromChild` is the immediate child of `node` that the focused
node descends from. For directly-focused children of `node`,
`fromChild = focusedNode`. For deeper descendants (transient
under transient under root), `fromChild` is the focused node's
ancestor on the chain to `node`, one level below `node`.

#### 8.3.3 Coupling with engine query service

Chunk 5 doesn't reconcile the focused flow node's
`FlowState.FocusedNodeID` with `queries.Focused()`. The
expected coupling pattern (when HandleFrame integration lands —
NOT chunk 5):

1. Frame N: game derives the focused UINode from the focused
   flow node:
   ```
   ownerID := tm.FocusedInstanceID()
   if ownerID == "":
       FrameOutput.Focused = ""
   else:
       owner, _ := tm.Lookup(ownerID)
       FrameOutput.Focused = owner.FlowState.FocusedNodeID
   ```
   `FrameOutput.Focused` is a `core.NodeID` — the UINode ID
   per the engine boundary (verified at
   [engine/pipeline/game_frame_handler.go:54](engine/pipeline/game_frame_handler.go#L54)).
   It is NOT `tm.FocusedInstanceID()`.
2. Engine compiles the rendered UI; the next frame's
   UIQueryService is seeded with that UINode focus.
3. Frame N+1: `queries.Focused()` returns the same UINode ID.
4. Routing reads `tm.FocusedInstanceID()` (internal flow-level
   state) and uses `queries.Navigate` (engine UINode-level
   state) for spatial nav.

The invariant the integration must maintain: `queries.Focused()`
on a given frame matches the currently focused flow node's
`FlowState.FocusedNodeID`. `tm.FocusedInstanceID()` is internal
to the routing layer — no engine-side counterpart.

If a chunk-5 test stubs queries with mismatched state, routing
gives wrong answers. Chunk 5's tests configure the synthetic
queries' Focused() / Navigate() to match the focused flow node's
FlowState.FocusedNodeID.

#### 8.3.4 Why no `Router` type

Otto greenlit (call 8) stateless free functions. Two reasons:
- No state lives in routing — all state is on TreeManager
  (focused id) and on FlowNodes (per-state FocusedNodeID).
- Avoids lifecycle management (when is the Router constructed?
  closed?). Tests call free functions directly with constructed
  deps.

If chunks 6+ find that routing accumulates state (e.g., a
deferred-focus retry queue across frames), introducing a Router
type then is straightforward.

#### 8.3.5 No HandleFrame integration

Per D11/D45: chunk 5 introduces no HandleFrame branching, no
per-mode flag wiring, no engine surface changes. Routing is
exercised exclusively through unit and integration tests.

The chunk that lands the engine/pipeline animator surface
alignment will also wire routing into HandleFrame's input phase.
That chunk also locks the FocusedNodeID-to-Selection.Key
resolution per D74.

---

## 9. Chunk 6 — Policies + HandleFrame Integration + Battle Proof

**Status:** Draft, pre-implementation.
**Goal:** Wire chunks 1–5 into the live HandleFrame path. Land
the engine surface alignment (animator on FrameContext), the
cutover switch (per-mode flag, ON for battle), policy aggregation
(pause/save/suppression), `dispatchOutcomes` over `engine/command`,
Selection.Key resolution, initial-focus retry, and the battle
proof end-to-end. Cue-driven `open_flow` is **deferred to chunk
8** per D83 — chunk 6 lands a `processCues` no-op stub awaiting
the cue-system migration. Chunk 6 is large by design — single
chunk per the §3 chunking decision.

This chunk resolves the deferrals tagged for it from chunks 3, 4,
and 5. After chunk 6 lands, battle mode runs through the new
runtime in production.

### 9.1 Chunk 6 decisions (D77–D95)

#### D77. Engine surface change — `Animator` on `FrameContext`

`pipeline.FrameContext` (verified at
[engine/pipeline/game_frame_handler.go:22](engine/pipeline/game_frame_handler.go#L22))
gains one new field:

```go
// engine/pipeline/game_frame_handler.go (modified in chunk 6)
type FrameContext struct {
    // ... existing fields ...

    // Animator is the active animator for the current frame. The
    // engine retains ownership of Advance/Apply/Sweep — game code
    // must NOT call those. Game code uses Animator only for
    // Reconcile (per design spec §12.3, called inside
    // TreeManager.Render).
    //
    // Populated by engine/runtime when constructing FrameContext.
    // May be nil in older test code that constructs FrameContext
    // directly — chunk 6's Runtime.HandleFrame explicitly rejects
    // nil with `opRuntimeNilAnimator` (reported to ErrorSink,
    // fallback FrameOutput returned). The legacy OFF-path (pre-
    // chunk-6 menu/flow) ignores this field so old tests that
    // don't populate it continue to pass.
    Animator *anim.Animator
}
```

`engine/runtime` (which constructs the `FrameContext`) populates
this field with its existing animator. Engine continues to drive
Advance/Apply/Sweep on the produced UINode trees as it does today;
HandleFrame uses the animator only to call `Reconcile` from inside
`TreeManager.Render` (chunk 3 D39).

This is the smallest possible surface change to enable chunk 3's
mechanics in production. Larger refactors (moving animator
ownership to game, exposing Apply/Sweep) are out of scope.

`engine/runtime` callers (the existing `FrameRunner` setup) need a
one-line addition: `frameCtx.Animator = fr.animator` (or
equivalent) at FrameContext construction.

#### D78. Cutover switch — per-mode flag in mode config

A boolean `UseTreeRuntime bool` is added to mode configuration.
Implementer chooses placement based on existing mode infrastructure
(`game/mode/mode.go` currently only declares the `GameMode`
constants; the flag may live in a new mode-config struct or in
the game module's mode-state mapping).

For chunk 6:
- `battle` mode: flag **ON**.
- `title` and `overworld`: flag **OFF**.

When ON for a mode:
- At mode-enter, the game module constructs a `*tree.TreeManager`
  + `*interpreter.Interpreter` for that mode and stores them on
  the mode-state.
- HandleFrame's render path delegates to the new runtime
  (`game/interaction/runtime.HandleFrame`, see D79).
- The old `game/menu/` and `game/flow/` paths are not invoked for
  this mode.

When OFF:
- HandleFrame falls through to the existing `buildScreenUIWithDefault`
  path. Old menu/flow runs as today. No tree manager is constructed.

The flag's exact wire (config struct, build flag, runtime feature
gate, etc.) is implementer-chosen. The contract:
- Per-mode boolean readable from HandleFrame.
- Default OFF for any mode whose chunk hasn't flipped it ON yet.
- Chunk 8 deletes the flag along with `game/menu/` and `game/flow/`.

#### D79. HandleFrame integration — `game/interaction/runtime/`

Chunk 6 introduces a new package `game/interaction/runtime/` that
owns the per-frame orchestration:

```
game/interaction/runtime/
  runtime.go          — Runtime struct, NewRuntime, HandleFrame
  cues.go             — processCues no-op stub (D83; chunk 8 will implement)
  outcomes.go         — dispatchOutcomes (FlowResult → DispatchedOutcome)
  selections.go       — Selection.Key resolution from FocusedNodeID
  focus.go            — initial-focus retry sweep + FrameOutput.Focused derivation
  errors.go           — op constants
  runtime_test.go
  cues_test.go
  outcomes_test.go
  selections_test.go
  focus_test.go
  testhelpers_test.go
```

The Runtime struct (full lock; the constructor and fields are
locked once below — there is no earlier "minimal" version):

```go
// HandleFrame is the per-mode-frame entry point. The game module
// calls this from its top-level pipeline.GameFrameHandler when the
// active mode has UseTreeRuntime == true.
//
// Signature matches pipeline.GameFrameHandler.HandleFrame —
// returns FrameOutput only, no error. Errors are reported to the
// supplied ErrorSink (see below) and the runtime falls through
// with a degraded but well-formed FrameOutput (last-good ScreenUI
// or empty Panel).
func (r *Runtime) HandleFrame(ctx pipeline.FrameContext) pipeline.FrameOutput
```

`pipeline.GameFrameHandler.HandleFrame` cannot return errors
(verified at
[engine/pipeline/game_frame_handler.go:18](engine/pipeline/game_frame_handler.go#L18)).
Internal errors during chunk 6's pipeline (route input failure,
render failure, derivation failure) are reported via:

```go
// game/interaction/runtime/runtime.go (chunk 6)

// ErrorSink is the runtime's local error-reporting interface.
// Defined locally (not imported from engine/errorsink) to keep
// the runtime layer guard (D90) free of engine/errorsink.
// Caller-supplied; the game module passes an adapter that
// forwards to the engine's actual error sink.
type ErrorSink interface {
    Report(err error)
}

type Runtime struct {
    tm             *tree.TreeManager
    interp         *interpreter.Interpreter
    compVM         *lua.LState         // = tm.CompVM(); identity-equal
    errorSink      ErrorSink           // local interface, supplied at NewRuntime
    lastScreenUI   core.UINode         // prior frame's; first frame zero
    lastSelections map[core.NodeID]string  // prior frame's; first frame empty
    lastGood       pipeline.FrameOutput     // last successful FrameOutput
}
```

**Persistent-flow startup lifecycle (chunk 6 lock).** At
`NewRuntime`, the runtime walks the tree's persistent children
and brings the interactive ones online before the first frame:

```
NewRuntime(tm, interp, sink):
    r := &Runtime{
        tm:             tm,
        interp:         interp,
        compVM:         tm.CompVM(),  // identity-equal to tree's VM (D95)
        errorSink:      sink,
        lastSelections: map[core.NodeID]string{},
    }

    // 1. Open every persistent interactive node so its FlowState
    //    is non-nil and the interpreter VM has loaded its schema
    //    module. (Open even if focus_entry == "none": a node that
    //    declines auto-focus may still receive ProcessInput later
    //    via direct routing, and OpenFlow is required for that.)
    //
    //    Among opened nodes, mark NeedsInitialFocus and pick the
    //    initial focus candidate ONLY for those that participate
    //    in focus: focusable AND focus_entry != "none".
    var initialFocusCandidate *tree.FlowNode
    for _, child := range tm.Root().Children:
        if child.Schema.Profile != schema.ProfileInteractive: continue
        if err := interp.OpenFlow(child); err != nil:
            // Partial-failure contract: NewRuntime is one-shot; on
            // any OpenFlow failure, return immediately. Earlier
            // children may have been opened; the caller MUST
            // discard this TreeManager and reconstruct rather than
            // retry NewRuntime (a retry would see opInterpAlreadyOpen
            // on the previously-opened nodes per chunk 4 D52).
            return nil, err
        // Skip non-focusable nodes from focus participation.
        if !child.Schema.Focusable: continue
        // Skip nodes that explicitly opted out via focus_entry="none".
        // These are interactive (ProcessInput-able) but never own
        // input automatically; transferring focus to them would
        // produce empty FocusedNodeID per chunk 5 D69.
        if child.Schema.FocusEntry.Kind == schema.FocusEntryNone: continue
        // Eligible: mark for first-frame focus resolution and
        // become the initial focus candidate (first such node in
        // declaration order).
        child.NeedsInitialFocus = true
        if initialFocusCandidate == nil:
            initialFocusCandidate = child

    // 2. Establish the initial focus owner. If no eligible
    //    persistent child exists (no interactive + focusable +
    //    non-"none" entry), leave focus empty; routing.HandleAttach
    //    for a transient flow will set it later.
    if initialFocusCandidate != nil:
        if err := tm.SetFocusedInstanceID(initialFocusCandidate.InstanceID); err != nil:
            return nil, err

    return r, nil
```

**Partial-failure contract.** If `interp.OpenFlow` succeeds for
some children but fails for a later one, NewRuntime returns the
error and **does not roll back** the partially-opened state. The
caller MUST treat this as terminal for that TreeManager:
discard it (drop references; rely on Go GC to release the LStates
the registries hold) and reconstruct from scratch if a retry is
needed. Retrying `NewRuntime` against the same TreeManager would
hit `opInterpAlreadyOpen` on previously-opened nodes (chunk 4
D52) and fail predictably.

This contract trades implementation complexity for caller
discipline: rollback would require coordinated cleanup across
interpreter, tree, and routing state, which is significant code
for a failure mode that mostly indicates bad authored content
(syntactically valid but runtime-broken schema). One-shot is
sufficient.

The persistent-flow init runs once at mode-enter. It does NOT
sweep deeper persistent descendants because non-root profiles
have no `persistent_children` (chunk 2 D26). Persistent depth
from the mode root is exactly 1.

**Initial-focus resolution path:**
- **Eligible** just-opened persistent interactive nodes —
  those that are `Focusable == true` AND
  `Schema.FocusEntry.Kind != FocusEntryNone` per the loop's
  filter — have `NeedsInitialFocus = true` and
  `FlowState.FocusedNodeID = ""`.
- Persistent nodes that are interactive but ineligible
  (non-focusable, or `focus_entry = "none"`) are opened (so
  ProcessInput can target them via direct routing) but skip
  the initial-focus mark — their `FocusedNodeID` stays empty
  and they never become initial focus owners.
- The first frame's `retryDeferredFocus` sweep (D82) walks
  the eligible nodes and resolves their `FocusedNodeID` against
  the rendered screenUI per `Schema.FocusEntry` (D69).
- For `command_panel` (focus_entry = "first_focusable"), the
  sweep picks the first focusable selectable in its emitted
  subtree — typically the first option emitted by `choice.lua`.

This closes the persistent-flow startup gap. After `NewRuntime`
returns, frame 1's HandleFrame:
1. UpdateViewStates (Phase 2) populates view_states.
2. Routing reads `tm.FocusedInstanceID()` = command_panel's
   instance, but `node.FlowState.FocusedNodeID` is still ""
   (subtree not yet rendered). The first frame's input dispatch
   therefore typically no-ops on Select (lastSelections is empty).
3. Phase 4.5 + 6: stepViews resolved, render produces selectables.
4. Phase 6.5: retryDeferredFocus assigns FocusedNodeID to the
   first focusable.
5. Frame 2: input dispatch sees a populated FocusedNodeID and
   non-empty lastSelections; Select works.

When an internal phase fails:
1. `r.errorSink.Report(err)` is called.
2. The Runtime falls through to compose a fallback FrameOutput
   via `r.fallbackOutput()`:
   - If `r.lastGood` is non-zero (i.e., a previous frame
     succeeded), return it.
   - Else return an empty FrameOutput with ScreenUI/WorldUI as
     bare Panels rooted at `tm.RootSchemaID()`.
3. The fallback output is returned. The next frame retries.

`HandleFrame` orchestrates the design-spec §12.2 phases (with
chunk 6's scope fences). The implementation reports errors via
ErrorSink and returns a fallback FrameOutput rather than
propagating an error — `pipeline.GameFrameHandler.HandleFrame`
returns `FrameOutput` only.

```
HandleFrame(ctx FrameContext) → FrameOutput:

    // Up-front guard: animator must be non-nil for chunk 6's path.
    if ctx.Animator == nil:
        r.errorSink.Report(failures.Rejectf(opRuntimeNilAnimator, "animator", "animator is nil"))
        return r.fallbackOutput()

    // Phase 1: Process cues. Chunk 6 D83: no-op stub for cue-driven
    //   open_flow; cue migration lands in chunk 8. Takes ctx (not
    //   ctx.Cues) to avoid naming the simcues type from runtime
    //   per D90's engine/sim forbid.
    if err := processCues(r, ctx); err != nil:
        r.errorSink.Report(err)
        return r.fallbackOutput()

    // Phase 2: Update view state for all visible nodes.
    if err := r.tm.UpdateViewStates(); err != nil:
        r.errorSink.Report(err)
        return r.fallbackOutput()

    // Phase 3: Advance animations.
    //   SKIP — engine drives Advance on its FrameRunner. Doing it
    //   here would double-advance.

    // Accumulator slices for outcomes; written in Phase 5.
    var commands []command.CommandRequest
    var hostEffects []pipeline.HostRequest

    // Phase 4: Route player input. Uses PRIOR-FRAME state:
    //   r.lastScreenUI for focus_entry / first_focusable resolution
    //   r.lastSelections for Selection.Key lookup from FocusedNodeID
    //   ctx.UIQueries seeded from prior frame's compiled UI
    if !policy.IsInputSuppressed(r.tm):
        flowResult, err := r.routeInputFromContext(ctx, r.lastScreenUI, r.lastSelections)
        if err != nil:
            r.errorSink.Report(err)
            // Continue rendering with no input applied; frame still
            // produces a valid FrameOutput.
            flowResult = interpreter.FlowResult{}

        // Phase 5: Handle outcomes from completed flows.
        out := dispatchOutcomes(flowResult)
        commands = append(commands, out.Commands...)
        hostEffects = append(hostEffects, out.HostEffects...)
        if out.OpenFlow != nil:
            if err := r.attachOpenFlow(*out.OpenFlow, r.lastScreenUI); err != nil:
                r.errorSink.Report(err)
                // Continue; the in-process attach failed but other
                // outcomes may still apply.
        if out.Transition != nil:
            // Per D80: chunk 6 logs and drops; live transition
            // wiring deferred. Test asserts the value via
            // dispatchOutcomes return path.
            r.errorSink.Report(failures.Rejectf(opRuntimeTransitionDropped, "transition",
                "transition %q dropped — host routing not wired in chunk 6", out.Transition))
        if out.QuitRequested:
            r.errorSink.Report(failures.Rejectf(opRuntimeQuitDropped, "quit",
                "quit dropped — host routing not wired in chunk 6"))

    // Phase 4.5 (chunk 6): pre-resolve step views for interactive
    //   nodes, projected onto the component VM. See D95.
    stepViews, err := r.resolveAllStepViews()
    if err != nil:
        r.errorSink.Report(err)
        return r.fallbackOutput()

    // Phase 6: Render. Components evaluate, bridge translates,
    //   animator.Reconcile is called per node.
    rendered, err := r.tm.Render(ctx.Animator, ctx.Style, stepViews)
    if err != nil:
        r.errorSink.Report(err)
        return r.fallbackOutput()

    // Phase 6.5 (chunk 6 addition): retry deferred initial focus
    //   for any node whose subtree just appeared in screenUI.
    retryDeferredFocus(r.tm, rendered.ScreenUI)

    // Phase 7: Apply animated values onto rebuilt UINode trees.
    //   SKIP — engine drives Apply on FrameOutput.ScreenUI/WorldUI
    //   after HandleFrame returns.

    // Phase 8: Reap exiting nodes whose exit animations completed.
    //   SKIP — exit lifecycle is chunk 7. Chunk 6's tree.Detach is
    //   synchronous (chunk 2 D29); no exiting state to reap yet.

    // Phase 9: Clean orphaned animation entries.
    //   SKIP — engine drives Sweep on FrameOutput.ScreenUI/WorldUI
    //   after HandleFrame returns.

    // Compose FrameOutput.Focused (UINode ID) from owner FlowState.
    focusedUINode := composeFocused(r.tm)

    out := FrameOutput{
        ScreenUI:    rendered.ScreenUI,
        WorldUI:     rendered.WorldUI,
        Commands:    commands,
        HostEffects: hostEffects,
        Focused:     focusedUINode,
    }

    // Cache for next frame's input routing.
    r.lastScreenUI = rendered.ScreenUI
    r.lastSelections = rendered.Selections
    r.lastGood = out

    return out
```

`r.fallbackOutput()` returns `r.lastGood` if non-zero, else an
empty Panel-rooted FrameOutput. The fallback ensures HandleFrame
always returns a well-formed FrameOutput even on internal error.

`r.lastSelections` is the previous frame's Selections map (D81).
Phase 4 routing uses it because the current frame's render
(Phase 6) hasn't happened yet. First frame has an empty map; no
selections resolve until Frame 2.

`r.lastScreenUI` similarly cached for the same prior-frame
reason. First frame uses an empty UINode.

Transition / OpenFlow / Quit handling: see D80.

#### D80. `dispatchOutcomes` — FlowResult → FrameOutput + side effects

`dispatchOutcomes` is **pure** — it translates a `FlowResult`
into structured return values; it does NOT mutate the runtime
or call attach helpers. The HandleFrame loop reads the returns
and acts on them.

```go
// game/interaction/runtime/outcomes.go (chunk 6)

// DispatchedOutcome carries the structured outcome of one
// FlowResult, ready for HandleFrame to apply.
type DispatchedOutcome struct {
    Commands      []command.CommandRequest
    HostEffects   []pipeline.HostRequest
    OpenFlow      *interpreter.OpenFlowRequest
    Transition    *mode.GameMode
    QuitRequested bool
}

// dispatchOutcomes converts a non-zero FlowResult into a
// DispatchedOutcome. Pure: no side effects.
func dispatchOutcomes(result interpreter.FlowResult) DispatchedOutcome
```

`HandleFrame` (D79) consumes the returned `DispatchedOutcome`:

- `Commands` → appended to FrameOutput.Commands.
- `HostEffects` → appended to FrameOutput.HostEffects.
- `OpenFlow` → if non-nil, HandleFrame calls
  `runtime.attachOpenFlow(req, screenUI)` (D83). The OpenFlow
  request does NOT go to the host pipeline; it's an in-process
  tree mutation. Rationale: the existing
  `pipeline.HostRequest{Kind: HostRequestOpenFlow}` carries a
  `FlowOpenSpec{FlowID, Key}` shape that doesn't match
  `interpreter.OpenFlowRequest{SchemaID, OpenContext}`. Reusing
  the host pipeline would require lossy translation; the
  in-process path is cleaner.
- `Transition` and `QuitRequested` — see below.

**Transition + Quit live host routing — DEFERRED to chunk 7 or 8.**
The current `pipeline.HostRequestKind` enum has
`HostRequestSetWindowMode`, `HostRequestOpenFlow`, and
`HostRequestWarnNotImplemented` (verified
[host_effect_types.go](engine/pipeline/host_effect_types.go)).
There is no `HostRequestModeTransition` or `HostRequestQuit`
kind today, and the engine-side `HostEffectExecutor` would also
need executor cases for any new kinds.

Chunk 6 does NOT touch these surfaces. `dispatchOutcomes`
returns `Transition *mode.GameMode` and `QuitRequested bool`,
and the chunk 6 tests assert these returned values directly. The
HandleFrame loop:

- For non-nil `Transition`: calls
  `r.errorSink.Report(failures.Rejectf(opRuntimeTransitionDropped, "transition", "..."))`
  and discards the value. No mode change occurs.
- For `QuitRequested = true`: calls
  `r.errorSink.Report(failures.Rejectf(opRuntimeQuitDropped, "quit", "..."))`
  and discards. No quit occurs.

This is **deliberate dead-letter behavior** for chunk 6: the
sentinel ops are recognizable by tests and ops dashboards
(once observability lands), making it visible that a transition
or quit was requested but not honored. Players invoking these
will see no effect; the implementer surfacing the dropped
ops in their chat-level summary makes it explicit which features
are deferred.

The full host-routing wire-up lands in chunk 7 (which may add
the kinds + executor cases) or chunk 8 (alongside deletion of
the legacy menu/flow path which has its own transition
mechanisms).

The chunk 6 battle proof does NOT exercise transition or quit;
the proof is `command_panel` → `battle_action` command. So
chunk 6 ships without the live transition/quit wire and the
proof still passes.

#### D81. Selection.Key resolution — renderer side-table

Chunk 5 D74 deferred this. Chunk 6 locks **renderer side-table**:

```go
// game/interaction/tree (chunk 6 retrofit)
//
// RenderOutput is the result of TreeManager.Render. Chunk 3
// returned (screenUI, worldUI core.UINode, err); chunk 6 wraps
// these in a struct alongside the SelectionMap so HandleFrame
// can resolve user-selected UINodes to schema option keys.
type RenderOutput struct {
    ScreenUI    core.UINode
    WorldUI     core.UINode
    Selections  map[core.NodeID]string  // selectable UINode ID → option key
}

// Render takes an animator + style context (chunk 3 D38) plus a
// step-views map (chunk 6 D95) keyed by InstanceID. Values are
// step-view tables already projected onto the component VM by
// the runtime's resolveAllStepViews(). Render passes the matching
// value to EvaluateStepComponent for each interactive node's
// active step. If a node's InstanceID is absent from the map,
// no step-component evaluation occurs (placeholder for the step
// area).
func (tm *TreeManager) Render(
    animator *anim.Animator,
    style style.StyleContext,
    stepViews map[string]*lua.LTable,
) (RenderOutput, error)
```

This is a **chunk 3 signature retrofit, extended in chunk 6**.
Chunk 3's `render_test.go` and `tree_integration_test.go` update
to:
- Use the new struct return shape (`out.ScreenUI`, `out.WorldUI`).
- Pass an empty/nil `stepViews` map for tests that don't
  exercise interactive-step rendering. (Nil map is valid; the
  render walk treats absent keys as "no step component to invoke.")

The Selections map is populated during the Render walk by reading
`core.UINode.Props.Selectable.Action` for each selectable UINode.
Phase 4's `choice.lua` step component (verified at
[game/assets/scripts/game/ui/step_components/choice.lua](game/assets/scripts/game/ui/step_components/choice.lua))
already sets `action = opt.key`, and the bridge maps that to
`SelectableProps.Action`. Chunk 6 leverages this existing
convention; no engine field addition needed.

```go
// game/interaction/tree/render.go (chunk 6 addition)
//
// collectSelections walks the rendered tree and records each
// non-disabled selectable UINode's Action as its option key.
// Disabled selectables (Selectable.Disabled == true) are EXCLUDED:
// stale focus pointing at a disabled selectable should not become
// a valid select target.
func collectSelections(node core.UINode, dst map[core.NodeID]string) {
    if sel := node.Props.Selectable; sel != nil && sel.Action != "" && !sel.Disabled {
        dst[node.ID] = string(sel.Action)
    }
    for _, child := range node.Children {
        collectSelections(child, dst)
    }
}
```

Chunk 6 walks the rendered ScreenUI (and WorldUI) once after
Render to build the Selections map. The walk is shallow per node
but recursive within the tree. `Disabled` selectables are
omitted; routing's select-action will fall through to the
interpreter's `opInterpSelectMissingSelection` if a player
somehow targets one.

HandleFrame (chunk 6) uses Selections during input dispatch:

```go
// game/interaction/runtime/selections.go (chunk 6)

func resolveSelectionForFocus(
    selections map[core.NodeID]string,
    focusedUINodeID core.NodeID,
) *interpreter.SelectionData {
    key, ok := selections[focusedUINodeID]
    if !ok { return nil }  // FocusedNodeID isn't a selectable
    return &interpreter.SelectionData{Key: key, Label: ""}
}
```

Label is left empty in chunk 6; if a future chunk needs labels at
runtime (e.g., for confirmation prompts), the SelectionMap value
becomes a struct.

Per chunk 5 D74, the absent-Selection case (no Selections entry,
or Action is "select" but FocusedNodeID isn't focused on a
selectable) falls through to interpreter, which raises
`opInterpSelectMissingSelection`.

#### D82. Initial-focus retry mechanism

Chunk 5 deferred: when `focus_entry = "first_focusable"` and the
new node's subtree is not yet in screenUI (just attached, not
yet rendered), routing sets `FocusedNodeID = ""` and relies on
chunks 6+ to retry.

Chunk 6 mechanism: each FlowNode gains a `NeedsInitialFocus bool`
field (in tree package, chunk 6 retrofit):

```go
// game/interaction/tree/tree.go (modified in chunk 6)
type FlowNode struct {
    // ... existing fields ...

    // NeedsInitialFocus is set by routing.HandleAttach when
    // focus_entry resolution couldn't complete because the
    // subtree wasn't in screenUI yet. Cleared once a subsequent
    // render fills it in and HandleFrame's retry sweep resolves
    // the focus.
    NeedsInitialFocus bool
}
```

Chunk 5's `routing.HandleAttach` is also retrofitted: when
focus_entry resolution returns `FocusedNodeID = ""` due to absent
subtree, HandleAttach sets `node.NeedsInitialFocus = true`.

Chunk 6's HandleFrame Phase 6.5 sweeps the tree for nodes with
`NeedsInitialFocus = true` and re-runs the focus_entry resolution
against the just-rendered screenUI. If resolved, clears the flag.

```go
// game/interaction/runtime/focus.go (chunk 6)

func retryDeferredFocus(tm *tree.TreeManager, screenUI core.UINode, sink ErrorSink) {
    for _, node := range tm.AllNodes():  // chunk 6 adds AllNodes() accessor
        if !node.NeedsInitialFocus: continue
        if node.FlowState == nil: continue  // not interactive/open
        id, deferred, err := routing.ResolveFocusEntry(
            tm.RootSchemaID(), node, screenUI,
        )
        if err != nil:
            sink.Report(err)
            node.NeedsInitialFocus = false  // give up on this node;
                                            // caller can retry by re-attaching
            continue
        if deferred:
            // Subtree still absent. Leave flag set; next frame's
            // sweep retries.
            continue
        // Resolved. Assign and clear.
        node.FlowState.FocusedNodeID = id
        node.NeedsInitialFocus = false
}
```

`tm.AllNodes()` is a new accessor on TreeManager (chunk 6 minor
retrofit). Per §9.3.6, iteration order is **deterministic and
matches insertion order**: persistent nodes first (in declaration
order from the mode root's `persistent_children`), then transient
nodes in attach order. Implementer maintains an ordered slice
alongside the existing `nodes` map (chunk 2 D25) so iteration
doesn't rely on Go map iteration order.

`resolveFocusEntryAgainstScreenUI` is the same logic as chunk 5
D69 but specifically for the retry case. Implementer either
extracts a shared helper from `routing/attach.go` (export
`ResolveFocusEntry`) or duplicates the logic in
`runtime/focus.go`. Lock: **export from routing**:

```go
// game/interaction/routing (chunk 6 retrofit)
//
// ResolveFocusEntry computes the focused UINode ID for a node
// given its schema's focus_entry rule, the prior FlowState, and
// the current screenUI. Three outcomes:
//
//   (id != "", deferred=false, err=nil): focus resolved; caller
//     assigns id to node.FlowState.FocusedNodeID and clears any
//     NeedsInitialFocus flag.
//   ("", deferred=true, err=nil): the node's subtree is absent
//     from screenUI (just attached and not yet rendered, or
//     between attach and first render). Caller leaves
//     FocusedNodeID = "" and sets/keeps NeedsInitialFocus = true
//     so a future HandleFrame retry will resolve it.
//   ("", deferred=false, err!=nil): real error (e.g.,
//     focus_entry = "named:<id>" but the named UINode is absent
//     from a present subtree). Caller propagates per its op.
//
// Used by HandleAttach (initial; chunk 5 D70) and
// runtime.retryDeferredFocus (retry; chunk 6 D82). Idempotent.
func ResolveFocusEntry(
    rootSchemaID string,
    node *tree.FlowNode,
    screenUI core.UINode,
) (id core.NodeID, deferred bool, err error)
```

#### D83. processCues — cue-driven `open_flow` deferred to chunk 8

The existing `CueKindOpenFlow` constant lives in `game/flow`
(verified — `game/ui_cues.go` references the legacy flow cue
shape). The chunk 6 runtime's layer guard forbids importing
`game/flow`. Reusing the legacy cue type would require either
(a) lifting the cue type into an allowed package, or (b)
defining a new tree-runtime cue kind alongside the legacy one
during the cutover window.

Both options touch cue infrastructure beyond chunk 6's scope.
**Chunk 6 defers cue-driven `open_flow` processing entirely.**

Chunk 6's `processCues` is a stub that ignores the supplied
`FrameContext` (including `ctx.Cues`) and returns nil. Cue-driven
`open_flow` lands with chunk 8 alongside the deletion of
`game/menu/` and `game/flow/`, when the legacy cue shape can be
deleted or replaced cleanly.

```go
// game/interaction/runtime/cues.go (chunk 6)

// processCues is a stub for chunk 8's cue-driven open_flow
// migration. **Ignores the frame context entirely and returns
// nil.** Takes ctx (rather than ctx.Cues) so chunk 6's runtime
// never names the simcues type — engine/sim/cues falls under
// engine/sim, forbidden by D90.
//
// Chunk 8 will implement cue-driven open_flow against the
// migrated cue infrastructure.
func processCues(r *Runtime, ctx pipeline.FrameContext) error {
    return nil
}
```

Taking `pipeline.FrameContext` (allowed by D90) rather than the
`simcues.CueBatch` field type avoids the engine/sim/cues import.
The chunk 6 stub doesn't read `ctx.Cues` (or any other field);
behavior is "always returns nil." Tests assert exactly that.

**OpenFlow via interpreter directive still works in chunk 6.**
When a schema function returns `{ open_flow = {...} }`, chunk 4's
interpreter populates `FlowOutcome.OpenFlow`. Chunk 6's
`HandleFrame` calls `runtime.attachOpenFlow` for that
in-directive case (D80). Cue-driven open_flow is the deferred
path; directive-driven open_flow works.

`r.attachOpenFlow` performs the chunk-5 trio:

```go
func (r *Runtime) attachOpenFlow(
    req interpreter.OpenFlowRequest,
    screenUI core.UINode,
) error {
    node, err := r.tm.Attach(req.SchemaID, req.OpenContext)
    if err != nil: return wrap(err)
    if err := r.interp.OpenFlow(node):
        return wrap(err)
    if err := routing.HandleAttach(r.tm, node, /* queries: */ nil, screenUI):
        return wrap(err)
    return nil
}
```

`queries` is nil because the just-attached node's subtree isn't
in screenUI yet; HandleAttach falls through to the deferred-focus
path. `animator` and `style` are removed from the signature —
HandleAttach doesn't use them (verified against chunk 5 D70).

#### D84. Pause aggregation — `policy.ResolvePausePolicy`

Per design spec §5.1: any granted pause request pauses simulation.

```go
// game/interaction/policy/policy.go (chunk 6)

// ResolvePausePolicy aggregates pause_request rules across all
// visible nodes per design spec §5.1. Returns true if simulation
// should be paused this frame.
func ResolvePausePolicy(tm *tree.TreeManager) bool {
    paused := false
    for _, node := range tm.AllNodes():
        if !node.Visible: continue
        switch node.Schema.PauseRequest:
        case schema.PauseNever:
            continue
        case schema.PauseWhileVisible:
            paused = true
        case schema.PauseWhileFocused:
            if onFocusPath(tm, node):
                paused = true
        case schema.PauseWhileInput:
            if tm.FocusedInstanceID() == node.InstanceID:
                paused = true
    return paused
}

func onFocusPath(tm *tree.TreeManager, node *tree.FlowNode) bool
```

`onFocusPath` checks if the named node is in `tm.FocusPath()`.

Chunks 1–5 already extract `Schema.PauseRequest` from the
authored schema (chunk 1 D14). Chunk 6 just consumes it.

**Wiring: game-side `ModeController.ResolveFramePolicy`, not
engine/runtime.** The existing pause control flow goes through
`pipeline.RuntimeControlHooks` and `game/mode_controller.go`'s
`ResolveFramePolicy` method (verified). Engine/runtime does NOT
import game packages. Chunk 6 extends `ModeController` to
consult `policy.ResolvePausePolicy(tm)` for modes whose
`UseTreeRuntime` flag is on:

```go
// game/mode_controller.go (modified in chunk 6)
//
// When the active mode has UseTreeRuntime == true, consult the
// new policy package via the runtime's TreeManager accessor.
// The legacy menu/flow path's pause logic is preserved for OFF
// modes.

// Runtime gains a TreeManager accessor (chunk 6 minor retrofit):
//   func (r *Runtime) TreeManager() *tree.TreeManager { return r.tm }

// In ModeController:
func (mc *ModeController) resolvePolicyForActiveMode(base FramePolicy) FramePolicy {
    if !mc.activeModeUsesTreeRuntime():
        return base  // legacy path; existing code unchanged

    rt := mc.activeRuntime  // *runtime.Runtime; chunk 6 stores this
    if rt == nil:
        return base  // tree runtime not yet constructed

    if policy.ResolvePausePolicy(rt.TreeManager()):
        // Pausing means the simulation stops advancing AND the
        // world clock stops. The exact field names on FramePolicy
        // depend on the actual type (verify against
        // game/mode_controller.go); typical:
        base.SimAdvance = false
        base.WorldClockRun = false
    return base
}
```

Implementer adapts to the actual `FramePolicy` field names —
`Pause` is not a literal field; the policy is expressed via
`SimAdvance` / `WorldClockRun` (or equivalent). Verify by
reading the existing struct.

The contract: chunk 6's `policy.ResolvePausePolicy` is consulted
by game-side mode control, never by engine/runtime directly.
Runtime's `TreeManager()` accessor is added as part of chunk 6's
minor TreeManager-related retrofits (alongside `RootSchema()`,
`AllNodes()`, suppression flags).

#### D85. Save aggregation — `policy.CanSave`

Per design spec §5.2: mode-level capability + node-level veto.

```go
// game/interaction/policy/policy.go (chunk 6)

// CanSave aggregates save_suppress rules per design spec §5.2.
// Returns true if save is currently allowed.
//
// First check: mode root's mode_save_policy. If "never", returns
// false unconditionally.
// Then: walk all visible nodes; if any save_suppress matches the
// current state, return false.
func CanSave(tm *tree.TreeManager) bool {
    rootSchema := tm.RootSchema()  // chunk 6 retrofit (see below)
    if rootSchema.Root.ModeSavePolicy == schema.ModeSaveNever:
        return false

    for _, node := range tm.AllNodes():
        if !node.Visible: continue
        switch node.Schema.SaveSuppress:
        case schema.PauseNever:    // SaveSuppress reuses the same enum values
            continue
        case schema.PauseWhileVisible:
            return false
        case schema.PauseWhileFocused:
            if onFocusPath(tm, node): return false
        case schema.PauseWhileInput:
            if tm.FocusedInstanceID() == node.InstanceID: return false

    return true
}
```

`tm.RootSchema()` is a new TreeManager accessor in chunk 6 (small
retrofit) that returns `tm.root.Schema`.

**Host wiring deferred to chunk 8**: chunk 6 exposes
`policy.CanSave(tm)` but the F5 save-callback gate retrofit (the
existing trigger infrastructure described in
`docs/systems/save_load.md`) is wired in chunk 8 D124. Chunk 6's
`policy.CanSave` is unit-tested but not exercised live until
chunk 8 reshapes the gate.

#### D86. Open / input suppression flags

Per design spec §5.3: two separate mode-root flags.

`tree.TreeManager` gains private fields and accessors (chunk 6
retrofit):

```go
// game/interaction/tree/manager.go (modified in chunk 6)

type TreeManager struct {
    // ... existing fields ...

    inputSuppressed bool
    openSuppressed  bool
}

func (tm *TreeManager) InputSuppressed() bool
func (tm *TreeManager) SetInputSuppressed(b bool)
func (tm *TreeManager) OpenSuppressed() bool
func (tm *TreeManager) SetOpenSuppressed(b bool)
```

`policy.IsInputSuppressed(tm)` and `policy.IsOpenSuppressed(tm)`
in the policy package are thin wrappers over the TreeManager
accessors:

```go
func IsInputSuppressed(tm *tree.TreeManager) bool { return tm.InputSuppressed() }
func IsOpenSuppressed(tm *tree.TreeManager) bool  { return tm.OpenSuppressed() }
```

Why both layers exist: tree owns the mutable state; policy
package is the public face for HandleFrame to read aggregate
policy. If chunks 7+ add more nuanced suppression logic (e.g.,
per-mode override based on visible nodes), it lands in policy.

Per design spec: `input_suppressed` implies `open_suppressed`.
Chunk 6's `IsOpenSuppressed` returns `tm.OpenSuppressed() ||
tm.InputSuppressed()` to honor this implication.

**HandleFrame integration** (D79):

- If `policy.IsInputSuppressed(tm)`: skip routing for player
  input (`RouteInput` is not called for non-cue-driven input).
  Cues still process (they're game-internal, not player input).
- If `policy.IsOpenSuppressed(tm)`: cues that would attach
  player-flow opens are skipped; system-level cues still attach.
  In practice, chunk 6 doesn't differentiate cue sources; the
  flag is wired for completeness but the semantics of "player vs
  system cue" are deferred.

#### D87. Visibility toggling for `input_suppressed`

Per chunk 5 / §10 deferral: when `input_suppressed` is on, what
happens to transient interactive nodes' visibility?

Lock for chunk 6: **`input_suppressed` does not change `Visible`**.
Visibility is independent of input suppression. The
`input_suppressed` flag suppresses input ROUTING (no
`RouteInput` calls); the rendered UI still shows. This matches
the design spec §5.3 "cutscene case" — the cutscene's UI is
visible but the player can't interact.

`Visible = false` is reserved for future chunks if a new use
case surfaces (e.g., explicit hiding of a flow node from render).
For chunk 6, `Visible` stays `true` for all nodes; the field
exists for future flexibility.

This means chunk 5 D74's TODO about wiring visibility for
input_suppressed is **resolved as no-op**: input suppression
does not toggle visibility.

#### D88. Focus coupling — write `FrameOutput.Focused`

`FrameOutput.Focused` is a `core.NodeID` per
[engine/pipeline/game_frame_handler.go:54](engine/pipeline/game_frame_handler.go#L54).

Chunk 6's HandleFrame derives it:

```go
func composeFocused(tm *tree.TreeManager) core.NodeID {
    ownerID := tm.FocusedInstanceID()
    if ownerID == "":
        return ""
    owner, ok := tm.Lookup(ownerID)
    if !ok: return ""  // tree drift; don't write garbage
    return owner.FlowState.FocusedNodeID  // may be "" if first_focusable deferred
}
```

This is the engine-boundary translation: flow instance_id (game-
internal) → UINode ID (engine-facing). Verifies chunk 5 D73's
contract.

#### D89. Input event translation — `pipeline.FrameContext.Input` → `RoutingInput`

The `routing.RoutingInput` shape (chunk 5 D67) is action-typed.
The current `pipeline.FrameContext.Input` is an
`input.FrameState` — raw platform input state. HandleFrame must
translate.

For chunk 6, the translation:

```go
// game/interaction/runtime/runtime.go (chunk 6)

func translateInputState(state input.FrameState) (routing.RoutingInput, bool) {
    // Returns (input, true) if there's a meaningful action this
    // frame; (zero, false) if no actionable input.
    //
    // Recognized actions (chunk 6 minimum set):
    //   - Confirm button just pressed → dispatched as
    //     RoutingActionConfirm or RoutingActionSelect based on
    //     the focused node's current step type (see below).
    //   - Cancel button just pressed → RoutingActionCancel.
    //   - D-pad just pressed in cardinal direction →
    //     RoutingActionNavLeft / Right / Up / Down.
    //
    // ...
}
```

The chunk 6 input translator inspects the focused node's active
step type before dispatching the confirm button:

```go
func actionForFocusedStep(tm *tree.TreeManager, button buttonKind) routing.RoutingAction {
    if button != ButtonConfirm: return ...
    ownerID := tm.FocusedInstanceID()
    owner, _ := tm.Lookup(ownerID)
    if owner == nil || owner.FlowState == nil: return RoutingActionSelect  // default
    step := owner.Schema.Interactive.Steps[owner.FlowState.CurrentStep]
    switch step.Type:
    case schema.StepConfirm:
        return RoutingActionConfirm
    case schema.StepChoice:
        return RoutingActionSelect
    default:
        return RoutingActionSelect  // shouldn't happen
}
```

This requires the runtime to read `tm.Lookup` + FlowState before
constructing `RoutingInput`. No retry-on-rejection logic — the
correct action is determined up front.

Lock: HandleFrame's input translator dispatches the controller's
confirm button to RoutingActionConfirm for confirm steps and
RoutingActionSelect for choice steps. The Cancel button always
maps to RoutingActionCancel.

The exact `input.FrameState` API — what button names are exposed,
how "just pressed" vs "held" is detected — is implementer-chosen
based on the existing input package. Chunk 6's runtime tests use
fake `input.FrameState` values configured per-test.

#### D90. Layer guard for `game/interaction/runtime`

```go
{
    ImporterBase: modulePath + "/game/interaction/runtime",
    ForbiddenBase: []string{
        modulePath + "/engine/runtime",
        modulePath + "/engine/sim",
        modulePath + "/engine/world",
        modulePath + "/engine/presentation",
        modulePath + "/engine/overlay",
        modulePath + "/engine/text",
        modulePath + "/engine/display",
        modulePath + "/engine/animation",
        modulePath + "/engine/session",
        modulePath + "/engine/errorsink",
        modulePath + "/engine/clock",
        modulePath + "/engine/ds",
        modulePath + "/engine/fs",
        modulePath + "/engine/script",
        modulePath + "/game/menu",
        modulePath + "/game/flow",
        modulePath + "/game/ui",
        modulePath + "/game/presentation",
        modulePath + "/game/proposals",
        modulePath + "/game/snapshot",
        modulePath + "/game/uidef",
        modulePath + "/game/worldstreaming",
        modulePath + "/render",
        modulePath + "/platform",
    },
    ExactImport: []string{
        modulePath + "/game",
    },
    Rule:        "layers.md: game/interaction/runtime may import engine/coretypes, engine/command, engine/input, engine/pipeline, engine/ui/anim, engine/ui/core, engine/ui/query, engine/ui/style, game/defs (for DeepCopyLuaTable cross-VM step-view projection per D95), game/interaction/{schema,tree,interpreter,routing,policy}, game/mode, failures, and gopher-lua only",
    Remediation: "runtime is the per-frame orchestrator over interaction/* and engine/pipeline; UI primitives + animator + queries are required for HandleFrame phases",
},
```

Allowed (by absence + narrowness):
- `engine/coretypes`, `engine/command`, `engine/input`,
  `engine/pipeline`, `engine/ui/anim`, `engine/ui/core`,
  `engine/ui/query`, `engine/ui/style`
- `game/defs` (for `DeepCopyLuaTable` use in D95's
  cross-VM step-view projection)
- `game/interaction/{schema,tree,interpreter,routing,policy}`,
  `game/mode`
- `failures`, `gopher-lua`

Forbidden: `game/menu`, `game/flow`, `game/ui/*` —
runtime doesn't touch them; engine/runtime side does.

`engine/sim/cues` was originally listed as allowed but is removed
because chunk 6 defers cue-driven open_flow (D83). The forbidden
prefix `engine/sim` correctly blocks `engine/sim/cues` (and
everything else under engine/sim) per the import-guard's
prefix-match behavior.

#### D91. Battle proof — production `command_panel` derivation

Chunk 4 noted the production derivation lands here. Author:

```lua
-- game/assets/scripts/game/ui/derivations/command_panel.lua (NEW in chunk 6)
local M = {}

function M.Derive(ecs, defs)
    -- Find the active fighter (first with attribute "active_turn"
    -- or similar; for chunk 6 minimal proof, just the first fighter).
    -- Real combat would track turn order; chunk 6 picks up a
    -- minimal proof.
    local fighters = ecs.find("fighter")
    local first = fighters[1]
    if first == nil then
        return { available_actions = {} }
    end
    return {
        available_actions = {
            { key = "attack",  label = "Attack" },
            { key = "defend",  label = "Defend" },
            { key = "ability", label = "Ability" },
            { key = "item",    label = "Item" },
        },
    }
end

return M
```

For chunk 6's battle proof, the action list is hardcoded — actual
filtering by fighter capabilities (active turn, available
abilities, etc.) is content for a later chunk. The proof is the
mechanism, not the gameplay. (Per D12 + D13.)

#### D92. Battle proof — `battle_action` `CommandHandler`

The simulation runner uses **a single** `command.CommandHandler`
implementation: `game.Module.Apply` (verified at
[game/command_handler.go:20](game/command_handler.go#L20)). The
`engine/command.CommandQueue` has no `RegisterHandler` method;
it only `Enqueue`s and `Drain`s. Dispatch by command type is
handled inside `Module.Apply` via a switch on `cmd.Type`.

Chunk 6 extends `Module.Apply` with a `battle_action` case:

```go
// game/command_handler.go (modified in chunk 6)

func (m *Module) Apply(ctx command.ApplyContext, cmd command.Command) error {
    switch cmd.Type {
    // ... existing cases (whatever they are) ...
    case "battle_action":
        return m.applyBattleAction(ctx, cmd)
    default:
        // existing default (likely a Reject failure for unknown
        // type, or no-op with warning).
    }
}

func (m *Module) applyBattleAction(ctx command.ApplyContext, cmd command.Command) error {
    // Minimal proof per D12: parse the payload and write a single
    // ECS field on a designated component. The round trip is
    // real (not mocked); the ECS effect is observable.
    //
    // Error classification follows the existing command-handler
    // contract: engine/runtime/simulation_runner.go classifies
    // rejection via command.IsReject(err). Use
    // command.NewRejectError for normal-game-state rejects (logged
    // at info level; tick continues). Other errors are treated as
    // faults (logged at error level). See engine/command/handler.go.
    var payload struct {
        Action string `json:"action"`
    }
    if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
        // Malformed payload is a fault — the producer (interpreter +
        // payload marshaller) should never have emitted invalid JSON.
        // Return a plain wrapped error, NOT a Reject.
        return fmt.Errorf("battle_action: invalid payload: %w", err)
    }

    // Write to ECS: set a "LastAction" field on the first fighter
    // returned by ECS query iteration. Chunk 6 doesn't yet have
    // turn-tracking infrastructure, so "first per the iterator"
    // is sufficient for the proof. The integration test reads
    // back via the same iterator (§9.2.4) so iteration order
    // alignment guarantees the test sees the handler's write.
    // The Fighter component's struct is extended with
    // `LastAction string` (chunk 6 content addition; see §9.2.2
    // modified files).
    //
    // Uses the engine/ecs Filter1 + Query1 API (verified at
    // engine/ecs/filter.go and engine/ecs/query.go):
    world := ctx.World()
    filter := ecs.NewFilter1[Fighter](world.Schema())
    q := filter.Query(world)
    if !q.Next() {
        // No fighters at command-apply time is a normal-game-state
        // reject — possible if the world transitioned out of battle
        // between command emission and apply. Use NewRejectError
        // so simulation_runner classifies it as a reject (logged
        // info, tick continues), not a fault.
        return command.NewRejectError(opBattleActionNoFighter,
            "no fighters registered for battle_action")
    }
    f := q.Get()  // *Fighter; mutable
    f.LastAction = payload.Action
    return nil
}
```

**Reject vs. fault contract** (locked in chunk 6):

| Failure                    | Classification | Mechanism                          |
|----------------------------|----------------|------------------------------------|
| Invalid JSON payload       | Fault          | `fmt.Errorf("...: %w", err)`        |
| No fighters at apply time  | Reject         | `command.NewRejectError(op, msg)`   |

Tests assert via `command.IsReject(err)`:
- `TestModuleApply_BattleAction_InvalidPayload` — asserts
  `command.IsReject(err) == false` (it's a fault).
- `TestModuleApply_BattleAction_NoFighters` — asserts
  `command.IsReject(err) == true` (normal reject).

`Query1[A].Get()` returns `*A` per
[engine/ecs/query.go:49](engine/ecs/query.go#L49); the mutation
is in-place. No setter pattern needed.

The case is added in `game/command_handler.go` (existing file),
not a new package. No layer-guard work needed.

**ECS effect locked**: chunk 6's battle proof writes a real
`Fighter.LastAction` field on the first fighter. The proof test
(§9.2.4) reads back the field after the round trip and asserts
the expected value. This is a true ECS-visible marker — `Fighter`
is part of the ECS world and persists through the existing
`SaveFile.ECSWorld` payload (`docs/systems/save_load.md` §b)
without any chunk-8 work needed.

`Fighter.LastAction string` is a chunk 6 content addition. If
the Fighter struct lives in a sealed location and adding the
field requires significant churn, the implementer surfaces in
chat-level summary; chunk 6 may need a small ECS-content
companion change. Adding a string field to an existing struct
is normally trivial.

#### D93. Battle mode cutover — flag ON

At module init, `battle` mode's `UseTreeRuntime` flag is set to
`true`. `title` and `overworld` remain `false`. The live game's
battle UX runs through the new runtime starting with chunk 6.

Title and overworld cutover happens in their landing chunks
(chunk 7 for animations may flip overworld; the title flip is
deferred until title content is authored).

#### D94. Layer guard for `game/interaction/policy`

```go
{
    ImporterBase: modulePath + "/game/interaction/policy",
    ForbiddenBase: []string{
        modulePath + "/engine/runtime",
        modulePath + "/engine/pipeline",
        modulePath + "/engine/sim",
        modulePath + "/engine/world",
        modulePath + "/engine/presentation",
        modulePath + "/engine/overlay",
        modulePath + "/engine/text",
        modulePath + "/engine/input",
        modulePath + "/engine/display",
        modulePath + "/engine/animation",
        modulePath + "/engine/session",
        modulePath + "/engine/command",
        modulePath + "/engine/errorsink",
        modulePath + "/engine/clock",
        modulePath + "/engine/ds",
        modulePath + "/engine/fs",
        modulePath + "/engine/ecs",
        modulePath + "/engine/ui",
        modulePath + "/engine/script",
        modulePath + "/game/menu",
        modulePath + "/game/flow",
        modulePath + "/game/ui",
        modulePath + "/game/defs",
        modulePath + "/game/mode",
        modulePath + "/game/presentation",
        modulePath + "/game/proposals",
        modulePath + "/game/snapshot",
        modulePath + "/game/uidef",
        modulePath + "/game/worldstreaming",
        modulePath + "/render",
        modulePath + "/platform",
    },
    ExactImport: []string{
        modulePath + "/game",
    },
    Rule:        "layers.md: game/interaction/policy may import game/interaction/{schema,tree}, failures only",
    Remediation: "policy is a thin aggregation layer over tree state and schema metadata; no engine deps",
},
```

Policy package is intentionally narrow: just walks
`tree.TreeManager` reading FlowNode + Schema fields. No engine
imports; no Lua; no command queue.

#### D95. Step-component rendering — chunk 3 retrofit for interactive nodes

Chunk 3 wired flow-node-level component evaluation
(`components.EvaluateComponent`). It did NOT wire step-component
evaluation (`components.EvaluateStepComponent`) for interactive
nodes' active step. The result: an interactive node like
`command_panel` would render placeholder UI (per chunk 3 D42
optionality) and emit zero selectable UINodes, breaking any
attempt at controller selection.

Per design spec §6.1: interactive nodes' step type selects a
component from the step-component library; the component renders
the step's data (options, labels) plus `ctx.view`.

**Ownership: runtime pre-resolves step views; tree consumes a
map.** Putting step rendering inside `tree.Render` would require
tree to import `game/interaction/interpreter` for
`ResolveStepView`, but `interpreter` already imports `tree`
(chunk 4 D48). That's a circular dependency. Three options were
considered:

- (a) Runtime pre-resolves step views and passes a map to
  `tree.Render`. Tree consumes data; no interpreter import.
- (b) Tree accepts a narrow `StepViewResolver` callback in
  Config. Lazy resolution mid-render.
- (c) Step rendering moves entirely to runtime; tree.Render
  produces only flow-node-level subtrees and runtime composes.

Lock: **(a) — runtime pre-resolves**. Cleanest data flow, no
interface in tree, single-pass tree render.

**Runtime side** — chunk 6 adds two helpers:

```go
// game/interaction/interpreter/process.go (chunk 6 addition)
//
// ResolveStepView builds the step-view table for the active
// step, reading static options or calling options_fn. Used by
// chunk 6's runtime to pre-resolve step views before render.
// Returned *lua.LTable lives on the interpreter VM.
//
// For 'choice' steps:
//   - Returns a table { options = {...}, font = "ui.body",
//     color = "ui.text" }. The font/color defaults are required
//     by Phase 4's choice.lua (verified at
//     game/assets/scripts/game/ui/step_components/choice.lua),
//     which reads step_view.font and step_view.color when
//     emitting selectables. If node.ViewState contains "font"
//     or "color" string fields, those override the defaults.
// For 'confirm' steps:
//   - Returns a table { summary = ..., font = "ui.body",
//     color = "ui.text" }, where summary is summary_fn's return
//     value. Same font/color override pattern.
//
// Calls to options_fn / summary_fn use the same accum-snapshot
// rollback contract as ProcessInput (chunk 4 D59).
func (ip *Interpreter) ResolveStepView(node *tree.FlowNode) (*lua.LTable, error)
```

```go
// game/interaction/runtime/runtime.go (chunk 6)
//
// resolveAllStepViews walks all visible interactive nodes,
// calls Interpreter.ResolveStepView for each, then deep-copies
// the result onto the component VM via defs.DeepCopyLuaTable.
// Returns a map keyed by InstanceID with values living on the
// component VM, ready for tree.Render to pass to
// EvaluateStepComponent.
func (r *Runtime) resolveAllStepViews() (map[string]*lua.LTable, error) {
    out := map[string]*lua.LTable{}
    for _, node := range r.tm.AllNodes() {
        if !node.Visible: continue
        if node.Schema.Profile != schema.ProfileInteractive: continue
        if node.FlowState == nil: continue  // not opened
        viewOnInterp, err := r.interp.ResolveStepView(node)
        if err != nil: return nil, err
        viewOnComp, err := defs.DeepCopyLuaTable(r.compVM, viewOnInterp)
        if err != nil: return nil, err
        out[node.InstanceID] = viewOnComp
    }
    return out, nil
}
```

**Component VM identity** — `r.compVM` MUST be the same
`*lua.LState` as `tm.cfg.CompVM`. A `*lua.LTable` allocated on
one LState cannot be passed to another. Chunk 6 enforces this by:

- Adding `TreeManager.CompVM() *lua.LState` accessor (small
  retrofit on tree package) that returns `tm.cfg.CompVM`.
- `NewRuntime` reads the VM from the supplied tree manager:
  `r.compVM = tm.CompVM()`. The constructor signature drops
  the `compVM` parameter.

Updated constructor:

```go
func NewRuntime(
    tm *tree.TreeManager,
    interp *interpreter.Interpreter,
    sink ErrorSink,
) (*Runtime, error)
```

This guarantees `defs.DeepCopyLuaTable(r.compVM, ...)` produces
tables on the same LState that `tm.cfg.CompVM` will use when
`tree.Render` later calls `EvaluateStepComponent`. Single source
of truth: the tree manager's CompVM.

**Tree side** — chunk 3's `Render` signature gains a parameter:

```go
// game/interaction/tree/render.go (modified in chunk 6)
//
// stepViews is keyed by InstanceID; values are step-view tables
// already projected onto the component VM. Render passes the
// matching value to EvaluateStepComponent for each interactive
// node's active step. If a node's InstanceID is absent from the
// map, no step-component evaluation occurs (placeholder for the
// step area).
func (tm *TreeManager) Render(
    animator *anim.Animator,
    style style.StyleContext,
    stepViews map[string]*lua.LTable,
) (RenderOutput, error)
```

Tree's render walk for interactive nodes:

```go
// Inside the per-node render step (interactive case):
if node.Schema.Profile == ProfileInteractive && node.FlowState != nil {
    stepView, ok := stepViews[node.InstanceID]
    if !ok {
        // No step view supplied; treat as placeholder.
        stepView = nil  // or skip the EvaluateStepComponent call entirely
    } else {
        step := node.Schema.Interactive.Steps[node.FlowState.CurrentStep]
        scope := bridge.TranslateScope{
            Scope:    tm.rootSchemaID,
            Instance: node.InstanceID,
        }
        stepTree, err := tm.cfg.CompReg.EvaluateStepComponent(
            tm.cfg.CompVM, string(step.Type), stepView, scope,
        )
        if err != nil { return zero, err }
        // Compose: append step tree as a child of the
        // flow-node-level tree (or replace the placeholder
        // entirely for nodes without a flow-node-level component).
        ...
    }
}
```

`EvaluateStepComponent` exists at
[game/ui/components/evaluate.go:38](game/ui/components/evaluate.go#L38)
and returns `(core.UINode, error)`. The choice.lua step component
(verified at
[game/assets/scripts/game/ui/step_components/choice.lua](game/assets/scripts/game/ui/step_components/choice.lua))
emits selectable UINodes with `action = opt.key` per Phase 4
convention.

**Caller flow in HandleFrame** (D79):

```
1. UpdateViewStates
2. resolveAllStepViews → stepViews map
3. tm.Render(animator, style, stepViews) → RenderOutput
```

**Why option (a) over (b)/(c):**
- (a) Tree imports nothing new. Runtime owns the cross-package
  coordination.
- (b) requires tree to declare a `StepViewResolver` interface;
  cleaner than (c) but adds a tree-level abstraction for the
  chunk 6 use case only.
- (c) splits the render walk across two packages; harder to
  reason about UINode IDs and composition.

Authored content: chunk 6 does NOT need a new flow-node-level
component for `command_panel` — the step component renders the
selectables. Chunk 7 may add a flow-node-level component for
visual polish; chunk 6 leaves it as placeholder per chunk 3 D42.

### 9.2 Chunk 6 acceptance

#### 9.2.1 New files

```
game/interaction/runtime/
  runtime.go
  cues.go
  outcomes.go
  selections.go
  focus.go
  errors.go
  runtime_test.go
  cues_test.go
  outcomes_test.go
  selections_test.go
  focus_test.go
  testhelpers_test.go

game/interaction/policy/
  policy.go
  errors.go
  policy_test.go

game/assets/scripts/game/ui/derivations/
  command_panel.lua            (NEW: production derivation)
```

No new package for battle_action handling — the existing
`game/command_handler.go` is extended with a `battle_action`
case in `Module.Apply` (D92).

#### 9.2.2 Modified files

- `engine/pipeline/game_frame_handler.go` — add `Animator
  *anim.Animator` field to `FrameContext`. Per D77.
- `engine/runtime/...` — populate `frameCtx.Animator` at
  FrameContext construction (one-line change in `FrameRunner`'s
  HandleFrame call site).
- `game/interaction/tree/tree.go` — add `NeedsInitialFocus bool`
  to `FlowNode`. Per D82.
- `game/interaction/tree/manager.go` — add `inputSuppressed`,
  `openSuppressed` fields + accessors. Add `AllNodes()`,
  `RootSchema()`, `CompVM()`, `Root()` accessors per D82 + D85
  + D95 + persistent-flow startup lifecycle.
- `game/interaction/tree/render.go` — change `Render` signature
  to `(animator, style, stepViews) (RenderOutput, error)` per
  D81 + D95. **Render-cascade**: every existing caller of
  `Render` must be updated to (a) pass an empty/nil `stepViews`
  map argument when not exercising step-component rendering, and
  (b) consume the new `RenderOutput` struct return shape instead
  of the chunk 3 triple `(screenUI, worldUI, err)`. Affected
  files include all chunk 3 `render_test.go` cases, any chunk 5
  routing tests that call Render through helpers, and chunk 6's
  own integration test in `game/tree_integration_test.go`.
- `game/interaction/tree/manager_test.go`,
  `game/interaction/tree/render_test.go` — adapt to RenderOutput
  signature.
- `game/interaction/routing/attach.go` — set
  `node.NeedsInitialFocus = true` when focus_entry resolution
  defers; export `ResolveFocusEntry` per D82.
- `game/interaction/routing/attach_test.go` — adapt to new
  exported helper.
- `game/module.go` and/or `game/run.go` — wire mode flag;
  construct Runtime when flag is ON; delegate HandleFrame to
  Runtime.HandleFrame.
- `game/frame_handler.go` — branch on the per-mode flag; OFF
  path unchanged, ON path delegates to Runtime.
- `game/command_handler.go` — add `battle_action` case to
  `Module.Apply` (D92).
- `game/components.go` — extend the `Fighter` ECS component
  struct with `LastAction string` field per D92. Required for
  the battle proof's ECS-visible effect.
- `game/mode_controller.go` — extend `ResolveFramePolicy` (or
  equivalent) to consult `policy.ResolvePausePolicy(tm)` for
  modes whose `UseTreeRuntime == true` (D84).
- `game/interaction/interpreter/process.go` — add
  `Interpreter.ResolveStepView(node)` per D95, used by chunk 6's
  step-component rendering retrofit.
- `game/interaction/tree/render.go` — extend the render walk to
  call `EvaluateStepComponent` for interactive nodes' active
  step (D95 chunk 3 retrofit).
- `internal/importguard/import_guard_test.go` — add
  `game/interaction/runtime` rule (D90) and
  `game/interaction/policy` rule (D94).
- `game/assets/catalog.json` — regenerated by `tools/cataloggen/`.
- Chunks 2/3/4/5 integration test
  (`game/tree_integration_test.go`) — extended with chunk 6
  scenarios.

#### 9.2.3 Test matrix

This section is large; chunk 6 has many components.

`runtime_test.go`:
- `TestRuntime_HandleFrame_FullPipeline` — synthetic schemas
  (battle root + one interactive child); FrameContext stub with
  fake animator and queries; assert all phases run in order;
  FrameOutput populated.
- `TestRuntime_HandleFrame_NoFocus_NoInputDispatch`.
- `TestRuntime_HandleFrame_NilAnimator_FallbackOutput` — Animator
  nil; ErrorSink receives `opRuntimeNilAnimator`; FrameOutput is
  the fallback (empty Panel-rooted) since lastGood is empty on
  first frame.
- `TestRuntime_HandleFrame_RouteInputError_FallbackContinues` —
  routing fails internally; ErrorSink reports; HandleFrame
  continues without input applied; FrameOutput still produced.
- `TestRuntime_HandleFrame_RenderError_FallbackUsesLastGood` —
  Render fails after at least one prior successful frame;
  fallback uses lastGood.
- `TestRuntime_HandleFrame_FocusedComposedFromOwner` —
  FrameOutput.Focused equals the focused owner's FlowState.FocusedNodeID.
- `TestRuntime_HandleFrame_LastSelectionsCachedAcrossFrames` —
  frame N's render Selections are read by frame N+1's input
  dispatch; selection works on the second-frame select.
- `TestRuntime_InputSuppressed_SkipsRouting`.

`runtime_test.go` input translation (D89):
- `TestInputTranslator_ConfirmButton_OnConfirmStep_DispatchesConfirm` —
  focused node has an active step of type `confirm`; controller
  Confirm button maps to `RoutingActionConfirm` (NOT Select).
- `TestInputTranslator_ConfirmButton_OnChoiceStep_DispatchesSelect` —
  focused node has an active step of type `choice`; controller
  Confirm button maps to `RoutingActionSelect`.
- `TestInputTranslator_CancelButton_AlwaysDispatchesCancel` —
  Cancel button maps to RoutingActionCancel regardless of step type.
- `TestInputTranslator_DPad_DispatchesNavDirection` — D-pad up /
  down / left / right map to NavUp / NavDown / NavLeft / NavRight.
- `TestInputTranslator_NoFocusOwner_NoActionDispatched` —
  `tm.FocusedInstanceID() == ""`; translator returns no
  RoutingInput; HandleFrame skips Phase 4.

`runtime_test.go` step-view resolution (D95):
- `TestRuntime_ResolveAllStepViews_StaticOptions` — synthetic
  schema with static `options`; resolveAllStepViews returns a
  map with one entry (the visible interactive node) whose
  step-view table contains the options.
- `TestRuntime_ResolveAllStepViews_OptionsFn` — synthetic
  schema with `options_fn` reading ctx.view; resolveAllStepViews
  calls options_fn and returns the dynamic options.
- `TestRuntime_ResolveAllStepViews_CrossVMProjection` — assert
  the returned `*lua.LTable` lives on the component VM (not the
  interpreter VM). Implementer chooses the assertion mechanism
  (e.g., construct comp VM, capture, verify table accessible
  there).
- `TestRuntime_ResolveAllStepViews_OptionsFnRaised_PropagatesError`.
- `TestRender_InteractiveNode_StepComponentInvoked` — render-side
  test (in tree's render_test.go) — synthetic interactive node
  with stepView supplied via the new map argument; assert
  EvaluateStepComponent was invoked and emitted selectables.

`cues_test.go`:
- `TestProcessCues_NoOp_ReturnsNil` — call processCues with a
  default `pipeline.FrameContext` (zero-value Cues); assert nil
  return. Confirms chunk 6 D83's deferral: the function is a
  stub awaiting chunk 8's cue migration.

The non-empty-cue test ChatGPT initially considered would
require constructing a `simcues.CueBatch`, which means importing
`engine/sim/cues` from the runtime test file. Since the runtime
package's layer guard (D90) forbids `engine/sim`, the test would
need to live elsewhere (e.g., `game/tree_integration_test.go` in
package `game`). Chunk 6 keeps the runtime-package test purely
no-op until chunk 8 migrates cues to an allowed location.

`outcomes_test.go`:
- `TestDispatchOutcomes_Commands_AppendedToReturned`.
- `TestDispatchOutcomes_HostEffects_AppendedToReturned`.
- `TestDispatchOutcomes_OpenFlowReturnedAsRequest` — open_flow
  directive surfaces as `DispatchedOutcome.OpenFlow` non-nil;
  HandleFrame loop is responsible for calling attachOpenFlow.
- `TestDispatchOutcomes_TransitionReturned` — `Transition`
  field non-nil; chunk 6 returns it but does NOT route to host.
  Live host routing deferred per D80.
- `TestDispatchOutcomes_QuitReturned` — `QuitRequested = true`;
  similar deferral.
- `TestDispatchOutcomes_EmptyOutcome_ZeroValueReturned`.

Runtime integration tests cover the open_flow follow-through:
- `TestRuntime_OpenFlowDirective_TriggersAttach` — directive
  with open_flow; HandleFrame's loop calls attachOpenFlow;
  next-frame retry resolves focus.

`selections_test.go`:
- `TestResolveSelectionForFocus_Hit` — Selections map contains
  the focused UINode; resolves to (Key, "").
- `TestResolveSelectionForFocus_Miss` — focused UINode not in
  Selections; returns nil. Interpreter raises
  `opInterpSelectMissingSelection` per chunk 4 D53.

`focus_test.go`:
- `TestRetryDeferredFocus_ResolvesNewSubtree` — node has
  `NeedsInitialFocus = true`; screenUI now contains its subtree
  with focusable; sweep resolves; flag cleared.
- `TestRetryDeferredFocus_StillNotInScreenUI_Stays` — flag stays
  true; FocusedNodeID stays empty.

`policy_test.go`:
- `TestResolvePausePolicy_NoVisibleNodes_NoPause`.
- `TestResolvePausePolicy_WhileVisibleGranted`.
- `TestResolvePausePolicy_WhileFocusedRequiresFocusPath`.
- `TestResolvePausePolicy_WhileInputRequiresInputOwning`.
- `TestResolvePausePolicy_NeverNeverPauses`.
- `TestCanSave_ModeSavePolicyNeverBlocks`.
- `TestCanSave_ModeAllowedNoSuppress_True`.
- `TestCanSave_NodeSuppressBlocks`.
- `TestIsInputSuppressed_TogglesViaSetter`.
- `TestIsOpenSuppressed_ImpliedByInputSuppressed`.

`tree/manager_test.go` additions:
- `TestSetInputSuppressed_TogglesField`.
- `TestSetOpenSuppressed_TogglesField`.
- `TestAllNodes_ReturnsAllRegisteredInInsertionOrder` —
  construct a tree with N persistent children + attach M
  transient children; assert AllNodes returns
  [root, persistent_0, ..., persistent_N-1, transient_0, ...,
  transient_M-1] in exactly that order.
- `TestRootSchema_ReturnsRootNodeSchema`.
- `TestRender_ReturnsRenderOutput` — adapt chunk 3 tests to the
  new signature.

`routing/attach_test.go` updates:
- `TestHandleAttach_DefersFocus_SetsNeedsInitialFocus` — when
  focus_entry resolution defers, `node.NeedsInitialFocus` is
  set to true.
- `TestResolveFocusEntry_ExportedHelper` — direct unit test of
  the now-exported helper.

`game/command_handler_test.go` additions:
- `TestModuleApply_BattleAction_ValidPayload` — JSON-encoded
  accum payload parses; first fighter's `LastAction` field is
  set to the payload's action string. Returns nil error.
- `TestModuleApply_BattleAction_InvalidPayload_IsFault` —
  malformed JSON; returns a non-nil error; assert
  `command.IsReject(err) == false` (classified as fault).
- `TestModuleApply_BattleAction_NoFighters_IsReject` —
  ECS world has no fighter entities; returns a non-nil error;
  assert `command.IsReject(err) == true` and the error op is
  `opBattleActionNoFighter`.

#### 9.2.4 Integration proof

`game/tree_integration_test.go` extended:

`TestBattleProof_CommandPanelToCommandHandler_RoundTrip`:

1. Setup as in chunks 2-5: registries (now including the new
   command_panel derivation), ECS world (3 fighters), DerivationVM,
   ComponentVM, Interpreter.
2. Construct a `runtime.Runtime` with TreeManager + Interpreter
   + ErrorSink-satisfying value (test sink that records errors).
3. The `*game.Module` (which implements `command.CommandHandler`
   via Module.Apply) is constructed — its `battle_action` case
   landed in chunk 6 D92.
4. **Drive Frame 1.** This frame's HandleFrame:
   - Phase 4 input dispatch is effectively a no-op for selection
     because `r.lastSelections` is empty on the first frame
     (per D79 caching pattern).
   - Phase 6 render produces command_panel's step-component
     output (selectable UINodes from `choice.lua`, per D95).
     `RenderOutput.Selections` is populated.
   - Phase 6.5 retryDeferredFocus assigns
     `command_panel.FlowState.FocusedNodeID` to the first
     focusable per chunk 5 D69 (`focus_entry = "first_focusable"`).
   - Frame 1 ends. `r.lastScreenUI` and `r.lastSelections` are
     cached for Frame 2's input dispatch.
5. **Drive Frame 2** — this is where Select actually works:
   - FrameContext with controller's **Confirm button** pressed
     (the same physical button that maps to either Select or
     Confirm depending on focused step type per D89).
   - Phase 4 input dispatch: routing's input translator inspects
     `command_panel.FlowState.CurrentStep` (a `choice` step) and
     dispatches the Confirm-button press as
     `RoutingActionSelect` per D89. Selection is resolved from
     `r.lastSelections[FocusedNodeID]` to
     `Selection{Key: "attack"}` (or whatever the first-focusable
     option's key was). The dispatched action reaches the
     interpreter via routing.
6. Call `runtime.HandleFrame(ctx)`. Assert no fallback (lastGood
   not used; ErrorSink unused).
7. FrameOutput.Commands contains a single `command.CommandRequest{
   Type: "battle_action", Payload: <JSON of accum>}`.
8. Stamp via `cmdQueue.Enqueue` (engine would do this; test does
   it manually).
9. Drain via `cmdQueue.Drain`; pass each command to
   `module.Apply` (engine would do this).
10. Assert the handler's effect on the **same fighter the handler
    targeted**. ECS iteration order is not guaranteed deterministic
    across archetype layouts, so the test reads back via the same
    iterator the handler used:

    ```go
    filter := ecs.NewFilter1[Fighter](world.Schema())
    q := filter.Query(world)
    if !q.Next() {
        t.Fatal("expected at least one fighter")
    }
    f := q.Get()
    if f.LastAction != "attack" {
        t.Errorf("LastAction = %q, want %q", f.LastAction, "attack")
    }
    ```

    Per D92, the proof is an ECS-visible write on the
    handler-selected fighter — the iterator's `Next()` semantics
    pick the same entity in the test as in the handler. If the
    integration test's setup spawns multiple fighters (e.g., 3
    for party_status), this iterator-aligned read is the contract.

This is the **D12 battle proof bar**: end-to-end round trip from
controller input through the new runtime — including step-component
rendering of selectables (D95), Selections resolution (D81), and
the existing Module.Apply dispatch — to an ECS effect, with no
mocks of the runtime mechanism.

`TestBattleProof_PauseMenu_PausesSimulation`:

1. Same setup; battle mode flag ON.
2. Open pause_menu (transient attach, focus transfers).
3. Call `policy.ResolvePausePolicy(tm)`.
4. Assert `true` (pause_menu's `pause_request = "while_visible"`).

`TestCutover_TitleMode_OffPathUnchanged`:

1. Mode flag for title is OFF.
2. Drive a frame for title mode.
3. Assert the old menu/flow path was used (assertion shape
   implementer-specific; probably check that no `Runtime` was
   constructed for title).

These three integration tests cover the chunk 6 deliverable
end-to-end.

#### 9.2.5 Validation gate

```
go run ./tools/cataloggen/ -check
go test ./game/interaction/...
go test ./game/...
go test -tags guard ./internal/importguard/...
go run ./tools/devctl testall
```

All must pass. The broadened `./game/...` scope catches the
integration tests, the battle_action handler tests in
`game/command_handler_test.go`, and the chunk 6 retrofit
cascading through chunk 3-5 packages.

### 9.3 Chunk 6 implementation notes

#### 9.3.1 Render signature retrofit

Chunk 3 returned `(screenUI, worldUI core.UINode, err error)`.
Chunk 6 changes to `(RenderOutput, error)` where `RenderOutput`
wraps the trees plus Selections. All chunk-3 tests update to the
new signature; chunks 4-5 tests don't depend on Render directly,
unaffected.

The Selections map can be empty for nodes that emit no
selectables (mode root placeholder, party_status passive). It's
non-nil but possibly zero-length.

#### 9.3.2 Runtime construction lifecycle

Per-mode construction at module init (game/run.go or similar):

```go
if modeConfig.UseTreeRuntime:
    tm, err := tree.NewTreeManager(treeCfg, modeRootSchemaID)
    if err != nil: return err
    interp, err := interpreter.New(scripts, schemas)
    if err != nil: return err
    sink := newRuntimeErrorSinkAdapter(engineErrorSink)  // local-interface adapter
    rt, err := runtime.NewRuntime(tm, interp, sink)
    if err != nil: return err
    modeState.runtime = rt
```

`NewRuntime` reads the component VM from `tm.CompVM()` (D95 lock
on identity). The persistent-flow startup lifecycle (D79) runs
inside `NewRuntime`: visible persistent interactive nodes are
opened, the first focusable becomes the initial focus owner,
and they're flagged for first-frame focus resolution.

Lifecycle: created at mode-enter, dropped at mode-exit (Go GC
handles release; `interp.Close()` is called on mode exit; tree
manager has no Close per chunk 2 D25).

#### 9.3.3 Phase order locked

The §12.2 phase order is locked in chunk 6. Future chunks may
extend (chunk 7 adds Phase 8 ReapExiting; chunk 8 adds session
delta hooks) but cannot reorder. Phase 6.5 (deferred-focus
retry) sits between Render and the FrameOutput composition;
chunks 7+ may expand this slot.

#### 9.3.4 `dispatchOutcomes` and `OpenFlow` in-process

Per D80, `OpenFlow` directive bypasses the host-effect pipeline
and is handled in-process via the runtime's `attachOpenFlow`.
This means a single HandleFrame call can attach a transient
flow, render it (placeholder UI initially since just-attached
node's subtree won't be in this frame's screenUI), and have its
focus deferred for the next frame's render.

The next frame's HandleFrame calls `retryDeferredFocus` which
finds the now-rendered subtree and sets focus.

This two-frame pattern is acceptable. Tighter "attach + render +
focus in one frame" would require re-rendering inside HandleFrame,
which is wasteful and not worth optimizing for chunk 6.

#### 9.3.5 Cutover OFF path unchanged

When the per-mode flag is OFF, `game/frame_handler.go`'s existing
code runs unchanged. Chunk 6 does not modify the OFF path. Old
tests for menu/flow continue to pass.

The branch shape:

```go
// game/frame_handler.go (modified in chunk 6)
func (m *Module) HandleFrame(ctx pipeline.FrameContext) pipeline.FrameOutput {
    if m.modeRuntime != nil {  // set only when current mode's flag is ON
        // Runtime.HandleFrame returns FrameOutput only; errors
        // are reported via its ErrorSink and handled internally
        // (fallback to lastGood or empty Panel-rooted output).
        return m.modeRuntime.HandleFrame(ctx)
    }
    // OFF path: existing buildScreenUIWithDefault flow.
    return m.legacyHandleFrame(ctx)
}
```

#### 9.3.6 Chunk 6's surface-change footprint

Engine surface changes (only one):
- `engine/pipeline.FrameContext.Animator *anim.Animator` (D77).

The transition/quit `HostRequestKind` additions discussed earlier
are NOT part of chunk 6 — D80 locks transition/quit as
dead-letter behavior, reporting through ErrorSink with
`failures.Rejectf(opRuntimeTransitionDropped, ...)` /
`failures.Rejectf(opRuntimeQuitDropped, ...)` errors. Live host
routing for transition/quit lands in chunk 7 or 8.

The `SelectableProps.OptionKey` field discussed earlier is NOT
added — D81 locks the existing `SelectableProps.Action` (Phase 4
convention; choice.lua already sets `action = opt.key`) as the
chunk 6 mechanism.

Tree-package retrofits (under D10's amended additive-helper rule):
- `tree.TreeManager.AllNodes()` — iterator over all registered
  nodes. **Iteration order is deterministic and matches insertion
  order**: persistent nodes first (in declaration order from the
  mode root's `persistent_children`), then transient nodes in
  attach order. Implementer maintains an ordered slice alongside
  the existing `nodes map` (chunk 2 D25) so iteration doesn't
  rely on Go map iteration order. None of chunk 6's consumers
  depend on order today (pause aggregation, save aggregation,
  retry sweep — all order-insensitive), but locking the contract
  prevents drift in chunks 7+.
- `tree.TreeManager.RootSchema()` — accessor for root node's
  schema.
- `tree.TreeManager.Root()` — accessor for the mode root FlowNode.
- `tree.TreeManager.CompVM()` — accessor for the component
  LState used by renders (D95 identity lock).
- `tree.TreeManager.SetInputSuppressed`, `SetOpenSuppressed`,
  `InputSuppressed`, `OpenSuppressed`.
- `tree.FlowNode.NeedsInitialFocus` field.
- `tree.RenderOutput` struct + `Render` signature change
  (`stepViews map[string]*lua.LTable` parameter, `RenderOutput`
  return).

Other-package retrofits:
- `routing.ResolveFocusEntry` — exported with three-outcome
  `(id, deferred, err)` signature.
- `interpreter.Interpreter.ResolveStepView(node)` — new method.

All called out in this chunk's D-list with rationale.

---

## 10. Chunk 7 — Animations

**Status:** Draft, pre-implementation.
**Goal:** Land the full animation lifecycle on the chunks 1-6
foundation. Schema-level enter/exit animations played at flow
node lifecycle transitions, exit lifecycle (mark exiting, freeze
view_state, reap when keys complete), shared parser
consolidation between schema-level (chunk 1) and component-
declared (Phase 4) surfaces, sequence-form support across both
surfaces, and the wrapper UINode that resolves chunk 1's D20
open question. Chunk 7 closes chunks 1's D21 sequence deferral
and §4.3.2's parser-consolidation TODO. State-driven (component-
declared) animations were already wired in chunk 3's Render via
`animator.Reconcile` (D39) — chunk 7 only extends that surface
with sequence-form parsing.

This chunk is moderate in scope: the bulk of `engine/ui/anim/` is
already complete (`Reconcile`, `Sweep(screenRoot, worldRoot)`,
`Apply`, `Advance`, `IsAnimating`, `AreKeysComplete`, single +
sequence forms). Chunk 7 adds two small additive engine surface
changes:

- **Validation export** — `engine/ui/anim` exports
  `ValidateAnimation` and `ValidateSequence` as thin wrappers
  over the existing private validators (D99 / §10.3.6) so the
  shared `animlua` parser can validate without duplicating
  logic.
- **Layout-compile passthrough patch** —
  `engine/presentation/render_compile.go` (or the layout-compile
  equivalent) gains a `NodeKindUnknown` single-child passthrough
  rule so the lifecycle wrapper's compiled rect equals its
  child's rect (D97 / §10.3.2). Required for wrapper Scale
  semantics.

Everything else is game-layer integration.

After chunk 7 lands, schema-authored enter/exit animations play
on flow node lifecycle, exit animations gate detach via
`AreKeysComplete`, and `battle_root` renders as a real component
instead of chunk 3 D42's placeholder.

### 10.1 Chunk 7 decisions (D96–D110)

#### D96. Schema-level lifecycle wrapper UINode — closes chunk 1 D20

Chunk 1 D20 deferred the lifecycle `nodeID` rule to chunk 7. Chunk
7 locks: **synthetic wrapper UINode**, ID =
`core.JoinNodeID(rootSchemaID, instance_id, "lifecycle")`.

Concrete: a flow node with `InstanceID = "battle_root.party_status"`
and the mode root's `rootSchemaID = "battle_root"` produces a
wrapper at NodeID `"battle_root/battle_root.party_status/lifecycle"`.
Schema enter/exit animations target this wrapper. The translated
component output remains under its existing ID
(`<rootSchemaID>/<instance_id>/<authored_root>`) and becomes the
wrapper's single child.

Rationale: every flow node — including those with no component
(chunk 3 D42 placeholder path) and including those whose component
fails to evaluate — has a stable, deterministic lifecycle ID. The
ID is independent of component output shape, so authoring schemas
that change their component layout doesn't break lifecycle
animations.

The `"lifecycle"` local segment is a fixed literal chosen to be
distinct from any plausible authored local ID (component authors
control the `<authored_root>` segment via their primitive tree's
root id). No collision risk with component-declared animations,
which target IDs derived from `ComponentOutput.Node.ID`.

#### D97. Wrapper UINode shape — passthrough kind, single child

```go
wrapper := core.UINode{
    ID:   core.JoinNodeID(rootSchemaID, node.InstanceID, "lifecycle"),
    Kind: core.NodeKindUnknown,           // emits no draw ops
    Children: []core.UINode{componentOutput}, // or chunk 3 D42 placeholder
    // No Props, no Anchor, no Layout, no Focus, no Actions.
}
```

`NodeKindUnknown` is the existing zero-value kind at
[engine/ui/core/types.go:72](engine/ui/core/types.go#L72) whose
documented contract is "emits no draw ops". Chunk 7 extends its
contract to also be **layout-passthrough**: a `NodeKindUnknown`
node with exactly one child is laid out as that child (compiled
rect identical to the child's rect; child's anchor honored as if
it were attached directly to the wrapper's parent).

Rationale: the wrapper exists solely as an animator target. It
must not perturb existing layout, hit testing, or focus
resolution. The only animator effects propagating from wrapper
to descendants are the §7 transforms documented in
[docs/ui_animation_system_spec.md](docs/ui_animation_system_spec.md):
`Opacity`, `OffsetPx`, `Scale` — these are already
descendant-inheriting in the existing animator/layout pipeline.

**Layout-compile patch — expected work, not optional.** The
existing layout-compile path does NOT special-case
`NodeKindUnknown` as a single-child passthrough — it compiles
unknown-kind nodes as ordinary layout nodes that emit no draw
ops, but their compiled rect is computed by the normal layout
pass. For lifecycle Scale to animate around the child's rect
center (not a zero-rect or default-rect wrapper), the wrapper
needs the passthrough rule.

Chunk 7 implementer adds the rule explicitly:

> When `node.Kind == NodeKindUnknown` and `len(node.Children) == 1`,
> the compiled rect of the wrapper equals the compiled rect of
> its single child after the child is laid out, with no
> independent layout-pass contribution from the wrapper itself.
> The child's authored Anchor is honored against the wrapper's
> parent (the wrapper is logically transparent for layout).

Tests for the rule live in the layout-compile package; chunk 7's
acceptance includes verifying scale-around-child-center works
correctly under the patch. This is not "potentially needed" —
chunk 7 lands the patch.

Wrapper Anchor is `nil` so the child's authored anchor (if any)
takes effect against the wrapper's parent. The wrapper does not
introduce a layout box that would clip or reposition the child.

#### D98. Wrapper retrofit to chunk 3 `TreeManager.Render`

Chunk 3 D39 / D42 / D43 currently emits per-flow-node UINodes
directly into the synthetic ScreenUI / WorldUI roots. Chunk 7
inserts the wrapper as the per-flow-node outermost UINode:

```
chunk 3 (current spec):
  ScreenUI
  └── component_output                  (or D42 placeholder)

chunk 7 (retrofit):
  ScreenUI
  └── wrapper (id = .../lifecycle)
      └── component_output              (or D42 placeholder)
```

Specifically, chunk 3's per-node render step (§6.1 D42 pseudocode)
returns a `core.UINode` per flow node. Chunk 7 wraps each return
value in a wrapper before composing into ScreenUI / WorldUI. The
wrapping happens inside `TreeManager.Render` after the
component-evaluation branch returns and after the placeholder
branch returns — both branches go through the same wrap step.

`out.Animations` (component-declared NodeAnimations from Phase 5)
are unaffected: they target IDs resolved against
`ComponentOutput.Node.ID`, not the wrapper. The chunk 3 D39
`animator.Reconcile` loop continues to fire on those IDs.

Wrapper insertion order vs. animator.Reconcile in `Render`: the
wrapper is built **before** Reconcile is called, so the wrapper
node is in the rendered tree by the time component-declared
animations are reconciled (Reconcile only writes animator state;
it doesn't read the tree). Whether wrapper-or-output ordering
matters for Reconcile: it does not. Reconcile is per-NodeID and
NodeIDs are pre-resolved.

**Mode-root wrapper: NOT wrapped in chunk 7.** The mode root is
the tree's outermost flow node, and its rendered output (whether
a real component per D109 or the chunk 3 D43 synthetic root
fallback) is itself the rendered ScreenUI / WorldUI root. Chunk
7 does NOT wrap the mode root. Root-profile schemas may declare
`enter_animations` / `exit_animations` and the parser (D99) will
accept them and store on the schema (D100), but the chunk 7
runtime never starts these animations — there is no wrapper
target to attach them to.

This is intentional: mode-root lifecycle conceptually means
"animate the entire mode in/out", which is mode-transition
work (chunk 8). Chunk 8 will add the mode-root wrapper at the
same time it wires `HostRequestModeTransition` and decides how
mode-transitions sequence enter/exit phases across the
outgoing/incoming mode roots.

Authoring guidance for chunk 7 timeframe: do not declare
`enter_animations` / `exit_animations` on root-profile schemas
yet — they parse but won't play. The chunk 8 deferred-list
entry tracks this.

#### D99. Shared anim parser package — closes chunk 1 D21 + §4.3.2

A new types-only sibling package houses the shared Lua-to-Go
animation parser:

```
game/ui/animlua/
  types.go        — Entry, ParseMode (public types)
  parser.go       — ParseEntries(*lua.LTable, mode ParseMode) (...)
  errors.go       — op constants
  parser_test.go
```

The package is leaf — no imports outside `engine/ui/anim`,
`engine/ui/anim/easing`, `engine/ui/style`, `failures`, and
gopher-lua. Both `game/interaction/schema` (chunk 1) and
`game/ui/components` (Phase 4 / existing) import from it.

Public surface:

```go
// ParseMode controls whether the `target` field is required, optional, or
// rejected. Schema-level enter/exit animations reject `target` (D100 +
// chunk 1 D20 / opSchemaAnimationTargetNotAllowed). Component-declared
// animations require `target` per existing animations.go contract.
type ParseMode uint8

const (
    ParseModeComponentDeclared ParseMode = iota // target required
    ParseModeSchemaLifecycle                    // target rejected
)

// Entry is one parsed animation declaration with both single-form and
// sequence-form options expressed by a discriminated union. Caller
// post-processes Entry into the destination type (anim.DeclaredAnimation
// for component-declared with target resolution; raw single/sequence
// slices for schema-level with no target).
type Entry struct {
    Key       string
    Target    string    // empty when ParseModeSchemaLifecycle
    Animation *anim.Animation  // nil iff Sequence is non-nil
    Sequence  *anim.Sequence   // nil iff Animation is non-nil
    DeclIndex int               // 1-based for error path construction
}

// ParseEntries parses an `anims` / `enter_animations` / `exit_animations`
// table into Entries. Validates per-entry shape, key/target presence per
// mode, sparse-array detection, RGBA-table form for color properties, and
// per-property value ranges (delegated to anim.ValidateAnimation /
// anim.ValidateSequence — exported in chunk 7 per §10.3.6). Returns
// Entries in declaration order. Multiple errors are joined.
func ParseEntries(tbl *lua.LTable, mode ParseMode, moduleKey string) ([]Entry, error)
```

**Engine surface change for validation export.** The existing
`engine/ui/anim/animator.go` validators `validateStart` and
`validateSequence` are unexported. Chunk 7 exports them as a
small additive engine surface change so animlua can validate
without duplicating logic and without instantiating a throwaway
animator:

```go
// engine/ui/anim/animator.go (chunk 7 modified)

// ValidateAnimation runs the same validation that Start would
// run, without mutating any animator state. Used by callers
// that need to validate an Animation spec at construction time
// (e.g., Lua parsers that produce typed Animations from
// authored content).
func ValidateAnimation(key string, a Animation) error {
    return validateStart(key, a)  // existing private impl, now also
                                   // reachable via this exported surface
}

// ValidateSequence is the analog for keyframe sequences.
func ValidateSequence(key string, s Sequence) error {
    return validateSequence(key, s)
}
```

These two exported functions are the only engine surface change
in chunk 7. Existing callers (`Start`, `StartGroup`,
`StartSequence`, `StartSequenceGroup`, `Reconcile`) continue to
use the private validators with no behavior change. Chunk 7
amends §10.3.6 surface-change footprint to record this.

Migration:

- Existing `game/ui/components/animations.go` reduces to: call
  `animlua.ParseEntries(animsTbl, animlua.ParseModeComponentDeclared, ...)`,
  then run the existing `resolveAnimTargets` to compose absolute
  NodeIDs and produce `[]NodeAnimations`.
- Chunk 1's `game/interaction/schema/extract.go` calls
  `animlua.ParseEntries(tbl, animlua.ParseModeSchemaLifecycle, ...)`
  for each of `enter_animations` and `exit_animations`. Result is
  stored on the schema as separate single + sequence slices (D100).

**Sequence-form support is now mandatory across both surfaces.**
Chunk 1 D21's "deferred to chunk 7" branch is closed by this
chunk's parser providing both forms uniformly. Chunk 1
implementer no longer needs the conditional sequence-support
branch — the prompt for chunk 1 will reference `animlua` as a
dependency that lands in chunk 7, with chunk 1's local extractor
as a temporary stand-in. (See §10.3 for the chunk-ordering
implication.)

#### D100. Schema enter/exit storage shape — singles + sequences split

Chunk 1's `Schema` types extend with two key-bearing types and
four slice fields. The types are declared in
`game/interaction/schema/types.go`:

```go
// LifecycleAnimation pairs a key with a single-form animation
// for schema-level enter/exit declarations. Lives in the schema
// package (not animlua) because the schema is its only consumer
// — animlua produces these via its Entry type, the schema stores
// them in their final form.
type LifecycleAnimation struct {
    Key       string
    Animation anim.Animation
}

// LifecycleSequence pairs a key with a sequence-form animation.
type LifecycleSequence struct {
    Key      string
    Sequence anim.Sequence
}

type Schema struct {
    // ... existing chunk 1 fields ...

    EnterAnimations []LifecycleAnimation
    EnterSequences  []LifecycleSequence
    ExitAnimations  []LifecycleAnimation
    ExitSequences   []LifecycleSequence
}
```

Two pairs (`Animations` / `Sequences`) not one tagged-union slice,
because chunk 7's lifecycle trigger calls the two-call group APIs
(`StartGroup` + `StartSequenceGroup`) atomically per D101 / D102.
Splitting at parse time avoids re-classifying at start time.

Key is stored alongside the spec because `anim.Animation` and
`anim.Sequence` themselves carry no Key field — the engine
substrate's animator APIs take the key as a separate parameter
(`Start(nodeID, key, anim)`, `StartGroup(nodeID, map[string]Animation)`).
The `LifecycleAnimation` / `LifecycleSequence` pair preserves the
key for both group-map construction (D101/D102) and ExitKeys
slice construction (D104).

`ExitKeys` is computed at exit-trigger time as
`node.Schema.ExitAnimations[*].Key ++ node.Schema.ExitSequences[*].Key`
(slice concatenation in iteration order). Stored on
`FlowNode.ExitKeys` by `MarkExiting`.

**Schema-level `target` field is rejected** at parse time. Two-op
sandwich:

- `animlua.ParseEntries` in `ParseModeSchemaLifecycle` rejects
  `target` with `opAnimLuaTargetNotAllowed` — the parser's own
  op for "this mode forbids targets".
- The schema package's call site (in `extract.go`'s enter/exit
  parsing) wraps any `opAnimLuaTargetNotAllowed` from animlua
  into `opSchemaAnimationTargetNotAllowed` via `failures.Wrap` so
  chunk 1 D20's promised error op is preserved at the schema
  surface. Tests at the schema layer assert
  `opSchemaAnimationTargetNotAllowed`; tests at the animlua
  layer assert `opAnimLuaTargetNotAllowed`.

Wrapping pattern (per-error precise translation; mixed-error
batches preserve other ops independently):

```go
// inside game/interaction/schema/extract.go enter/exit handler.
// animlua.ParseEntries may return errors.Join(...) with a mix of
// per-entry ops; we translate ONLY opAnimLuaTargetNotAllowed
// instances, leaving siblings untouched.
entries, err := animlua.ParseEntries(tbl, animlua.ParseModeSchemaLifecycle, moduleKey)
if err != nil {
    return entries, translateAnimLuaErrors(err)
}

// translateAnimLuaErrors walks an err that may be a single error
// or an errors.Join. Each leaf bearing OpAnimLuaTargetNotAllowed
// is replaced with a schema-layer wrap; all other leaves pass
// through unchanged. The output preserves the join shape
// (single → single, joined → joined of translated leaves).
func translateAnimLuaErrors(err error) error {
    leaves := unwrapJoined(err)  // []error; len 1 for non-joined
    out := make([]error, len(leaves))
    for i, leaf := range leaves {
        if failures.IsOp(leaf, animlua.OpAnimLuaTargetNotAllowed) {
            out[i] = failures.Wrap(failures.Reject, opSchemaAnimationTargetNotAllowed,
                fmt.Errorf("schema enter/exit animations cannot specify target: %w", leaf))
        } else {
            out[i] = leaf
        }
    }
    if len(out) == 1 {
        return out[0]
    }
    return errors.Join(out...)
}
```

This per-leaf translation matters when authored content has
multiple problems in one `enter_animations` array (e.g., one
entry with `target` + one entry with bad `duration`). Tests
that assert "schema rejects target with
`opSchemaAnimationTargetNotAllowed`" stay green; tests that
assert "schema rejects bad duration with
`opAnimLuaFieldRange`" (or whatever animlua's per-field op is)
also stay green from the same call. Without per-leaf
translation, a mixed-error batch would either lose the schema
op (whole-error pass-through) or lose the animlua op
(whole-error wrap).

The `unwrapJoined` helper is a small util that calls
`errors.Unwrap()` repeatedly — gopher-style — checking for the
multi-error interface that `errors.Join` produces. Implementer
locks the helper's exact form; the contract is "split
joined errors into leaves, leave non-joined errors as a
one-element slice".

No schema authoring may target sub-tree elements with lifecycle
animations — the wrapper is the only target, and the wrapper
owns Opacity / Offset / Scale propagation to descendants.

#### D101. Lifecycle enter trigger — `NeedsEnterAnimations` flag

`tree.FlowNode` gains:

```go
type FlowNode struct {
    // ... existing chunk 2 / chunk 6 fields ...

    NeedsEnterAnimations bool  // set on attach; consumed on first Render
}
```

Setpoints:

- **Persistent interactive nodes:** `NewRuntime` (chunk 6 D79)
  sets the flag during persistent-flow startup, in the same loop
  that calls `interp.OpenFlow`. Per Otto's chunk 7 confirmation,
  persistent nodes get enter animations on mode entry — this
  matches the player's perception that the mode "appears" with
  the persistent UI visible.
- **Persistent passive nodes:** chunk 2 D26 attaches them
  during `NewTreeManager`; the flag is set in that constructor
  for nodes whose schema has any `EnterAnimations` /
  `EnterSequences`.
- **Transient nodes:** chunk 4's `Attach` / `AttachTo` (chunk 2
  D30) sets the flag at attach time when the schema declares
  enter animations.

Consumption: `TreeManager.Render` checks the flag during each
flow node's render step. If set:

1. Compose the wrapper ID via D96.
2. Build an `Animation` map from `Schema.EnterAnimations` (key →
   spec) and call `animator.StartGroup(wrapperID, animMap)`.
3. Build a `Sequence` map from `Schema.EnterSequences` (key →
   seq) and call `animator.StartSequenceGroup(wrapperID, seqMap)`.
4. Clear the flag.

Either group call is skipped when its source slice is empty.
Both calls are skipped when both slices are empty (no enter
animations declared); the flag is still cleared.

Failure mode: `StartGroup` / `StartSequenceGroup` return errors
on duplicate properties within their respective group (engine
substrate per
[engine/ui/anim/animator.go:108-113](engine/ui/anim/animator.go#L108)).
The animlua parser (D99) already detects duplicate keys AND
duplicate properties within each parsed list, so the runtime
call cannot fail on those grounds under correct authoring. If
it does fail (a sign of a parser bug or a load-time invariant
violation), `Render` returns the error per chunk 3 D39's pattern:

```go
// Inside TreeManager.Render's per-flow-node loop:
if node.NeedsEnterAnimations {
    // Clear the flag regardless of code path below: enter
    // animations are one-shot at first render. If skipped or
    // failed, they don't get retried on subsequent frames.
    node.NeedsEnterAnimations = false

    if node.Exiting {
        // Exiting wins over entering: don't start enter anims for
        // a node that's already in exit phase. This can happen
        // when a transient flow attaches and immediately hits a
        // complete/cancel directive in the same frame's
        // dispatchOutcomes (chunk 7 D108 routes through
        // tm.MarkExiting). Without this guard, enter anims would
        // start on the wrapper and immediately get overridden by
        // exit anims via the property-owner conflict rule —
        // wasted animator work and a one-frame visual flicker.
        // Spec invariant: if Exiting, skip enter; flag is
        // consumed regardless.
    } else {
        err := tm.startEnterAnimations(node, animator)
        if err != nil {
            return RenderOutput{}, fmt.Errorf("enter animations for %q: %w", node.InstanceID, err)
        }
    }
}
```

The defer-style flag clear (clearing before the error check)
prevents the spec's failure mode from causing a per-frame retry
loop. Once attempted, the enter phase is over — success or
failure — and the flag is consumed.

`Runtime.HandleFrame` (chunk 6 D79) consumes Render's error
return and reports via its own `errorSink.Report(err)` call
followed by the fallback FrameOutput. This matches chunk 3
D39's component-declared `animator.Reconcile` error-propagation
pattern — TreeManager doesn't reach for the error sink directly;
it returns errors and lets the runtime report.

The `startEnterAnimations` helper is a TreeManager method (it
needs `tm.rootSchemaID` to compose the wrapper ID per D96):

```go
// startEnterAnimations builds the wrapper-targeted enter animation
// groups from node.Schema and calls animator.StartGroup /
// StartSequenceGroup against
// JoinNodeID(tm.rootSchemaID, node.InstanceID, "lifecycle").
// Returns the first error if either call fails.
func (tm *TreeManager) startEnterAnimations(node *FlowNode, animator *anim.Animator) error
```

Cross-property duplicate **across** the two calls
(e.g., a single animation and a sequence both targeting
`AnimOpacity` under different keys) is NOT validated at parse
time. Chunk 7 leaves it to runtime resolution via the engine's
existing property-owner conflict rule: last-started wins.
Authors who declare conflicting lifecycle animations across
single + sequence lists get deterministic but possibly
surprising results; documented as authoring guidance only.

#### D102. Lifecycle exit trigger — split between tree state and runtime orchestration

The exit transition is split into two responsibilities so the API
matches each layer's ownership:

- **`tree.TreeManager.MarkExiting`** — pure state mutation. No
  animator interaction, no error reporting. Owns: `Exiting` flag,
  `FrozenViewState` snapshot, `ExitKeys` population, focus
  handoff (D110).
- **`runtime.startExitAnimations`** — runtime-side orchestration.
  Owns: animator group calls, error reporting via the runtime's
  `errorSink` (chunk 6 D86). Called by the runtime's
  `dispatchOutcomes` complete/cancel handler (D108) immediately
  after `tm.MarkExiting`.

Tree-side API:

```go
// MarkExiting transitions a flow node to exiting state. Snapshots
// view_state, sets the Exiting flag, populates ExitKeys, and
// invokes routing's HandleExitStart for focus handoff (D110).
// Does NOT touch the animator.
//
// Idempotent: if node.Exiting is already true, returns nil
// without re-snapshotting view_state or re-calling
// HandleExitStart. This makes redundant complete/cancel
// directives during an in-flight exit safe (e.g., a frame that
// re-fires complete after MarkExiting on a previous frame).
//
// Persistent nodes (passive or interactive) panic (Bucket A
// per errors.md §5: caller-bug invariant violation; persistent
// nodes do not exit-and-detach within a mode's lifetime —
// chunk 4 D53 covers persistent interactive reset; persistent
// passive nodes only detach at mode end).
//
// Returns nil on success, or any error from HandleExitStart's
// focus restoration (very rare; logged but does not block the
// state mutation, which has already happened).
func (tm *TreeManager) MarkExiting(node *FlowNode) error
```

Implementation:

1. If `node.Exiting`: return nil (idempotent).
2. If `node.Persistent` (chunk 2 D24's `FlowNode.Persistent` field
   that mirrors `Schema.Persistent`): panic with a Bucket-A
   message identifying the caller. This single-field check covers
   both persistent interactive and persistent passive — chunk 2
   D24's field is set during `NewTreeManager` for any node
   constructed from `persistent_children`.
3. `node.FrozenViewState = node.ViewState` — same `*lua.LTable`
   pointer, no copy.
4. `node.Exiting = true`.
5. Compose `ExitKeys` from
   `node.Schema.ExitAnimations[*].Key ∪ ExitSequences[*].Key`.
   Store on `node.ExitKeys`.
6. Call `routing.HandleExitStart(tm, node)` (D110) for focus
   handoff. If HandleExitStart returns an error, retain it for
   the caller (return it from MarkExiting); the state mutation
   in steps 3-5 is already committed and is not rolled back.

Runtime-side orchestration:

```go
// startExitAnimations builds wrapper-targeted exit animation
// groups from the schema and calls animator.StartGroup /
// StartSequenceGroup. Both calls are independent so a failure
// in one does not skip the other; if both fail, the returned
// error is errors.Join(err1, err2).
//
// Lives in game/interaction/runtime/ since it touches the runtime's
// animator (ctx.Animator from chunk 6 D77) and reports via
// runtime.errorSink.
func (r *Runtime) startExitAnimations(node *tree.FlowNode, animator *anim.Animator) error
```

Implementation:

1. Compose wrapper ID =
   `core.JoinNodeID(r.tm.RootSchemaID(), node.InstanceID, "lifecycle")`.
2. Build `exitAnims map[string]anim.Animation` from
   `node.Schema.ExitAnimations[*].Key → .Animation` (skip if empty).
3. Build `exitSeqs map[string]anim.Sequence` from
   `node.Schema.ExitSequences[*].Key → .Sequence` (skip if empty).
4. `var errs []error`. Call `animator.StartGroup(wrapperID, exitAnims)`
   if non-empty; on error, append to `errs`.
5. Call `animator.StartSequenceGroup(wrapperID, exitSeqs)` if
   non-empty; on error, append to `errs`.
6. Return `errors.Join(errs...)` (`nil` when `errs` is empty —
   `errors.Join` returns nil for an empty slice).

The runtime's caller (in `dispatchOutcomes` per D108) reports
via `r.errorSink.Report(failures.Wrap(opLifecycleExitAnimationFailed, err))`
on non-nil return. The state transition in `tm.MarkExiting` has
already happened; failed exit animations just mean the node
reaps on the next frame (empty ExitKeys → immediate reap per
D105; partial-key-set animation also resolves to "complete"
because animator's `AreKeysComplete` only checks the keys it
knows about, and a key whose `StartGroup` failed never enters
the animator's state — so `AreKeysComplete([failed-key])`
returns true).

`MarkExiting` + `startExitAnimations` together do NOT detach
the node. Detach happens in `ReapExiting` (D106) when the
animator's `AreKeysComplete` returns true.

If `node.Schema.ExitAnimations` and `ExitSequences` are both
empty, `ExitKeys` is empty and `startExitAnimations` is
effectively a no-op; the node will reap on the very next
`ReapExiting` pass per D105.

#### D103. Frozen view_state — `UpdateViewStates` skips exiting nodes

Chunk 2 D32's `UpdateViewStates` walks all visible nodes and runs
their derivation. Chunk 7 amends:

> If `node.Exiting == true`, skip the derivation call. The node's
> `ViewState` field is treated as the frozen value
> (`FrozenViewState` mirrors it; the field is retained as a
> sanity-check copy and is not the read source). Render still
> picks up `node.ViewState` as before.

`FrozenViewState` is retained as a separate field for two
reasons: (a) defensive — in case some future chunk introduces a
code path that rewrites `node.ViewState` even on exiting nodes, the
frozen reference is recoverable; (b) makes the "frozen at this
moment" semantics explicit for tests.

This amends chunk 2 D32's "stop at first error" policy to also
skip exiting nodes entirely (no error possible because no
derivation runs). Chunk 1 anticipated this in §1124's note:
"Chunk 7 revisits this if the exit lifecycle wants frozen
view_state to survive transient derivation failures." Chunk 7
confirms: yes, exit phase = no derivation = no error opportunity.

#### D104. `tree.FlowNode` exit fields — chunk 2 deferral materializes

Chunk 2 D25 footnote at line 869 reserved `Exiting` and `ExitKeys`
for chunk 7. Chunk 7 materializes:

```go
type FlowNode struct {
    InstanceID         string
    Schema             *schema.Schema
    Parent             *FlowNode
    Children           []*FlowNode
    ViewState          *lua.LTable
    FlowState          interpreter.FlowState  // chunk 4

    // chunk 6 fields:
    Visible            bool
    NeedsInitialFocus  bool

    // chunk 7 fields:
    NeedsEnterAnimations bool
    Exiting              bool
    ExitKeys             []string
    FrozenViewState      *lua.LTable
}
```

Field ordering follows chunk-introduction order to preserve the
"new fields appended" convention chunks 1-6 used.

#### D105. Empty ExitKeys → reap immediately (closes M5)

Per chunk 7 micro-decision M5, when a flow node has no
`exit_animations` declared (`ExitKeys` empty), `ReapExiting`
detaches it on the very next pass:

```go
animator.AreKeysComplete(wrapperID, nil)  // returns true
animator.AreKeysComplete(wrapperID, []string{})  // returns true
```

The engine substrate's `AreKeysComplete` already returns true for
empty key slices (vacuous truth — verified at
[engine/ui/anim/animator.go:451-458](engine/ui/anim/animator.go#L451)).
No special-case in chunk 7's `ReapExiting`.

Behavioral consequence: a flow node with no exit animations,
when closed via `complete` / `cancel` directive (D108),
transitions through one frame in the exiting state (the frame in
which `MarkExiting` ran) and detaches at the start of the next
frame's `ReapExiting`. This is the same one-frame-of-exit-phase
behavior as nodes with exit animations whose keys complete in
zero time — consistent semantics regardless of whether exit
animations are declared.

#### D106. `tree.TreeManager.ReapExiting(animator)` — Phase 8

```go
// ReapExiting walks transient flow nodes and detaches those whose
// ExitKeys are complete on the animator. Persistent nodes are
// skipped — see persistent-node note below.
//
// ReapExiting uses tm.DetachExiting (D110), which removes the node
// without firing routing.HandleDetach — focus was already handed off
// at MarkExiting time via routing.HandleExitStart. The detached
// node's wrapper UINode does NOT survive into the next frame's
// render (Render rebuilds the tree fresh per chunk 3 immediate-
// mode rebuild).
//
// Animator state for the detached wrapper ID is cleaned up by the
// frame N+1 post-HandleFrame Sweep call (per D107 timing analysis),
// not the same-frame Sweep.
func (tm *TreeManager) ReapExiting(animator *anim.Animator)
```

**Persistent-node note.** In chunks 1-7, persistent nodes are
never `Exiting` because chunk 7 D102's `MarkExiting` panics on
them. Chunk 8 D117 changes this for the mode-end path: persistent
nodes ARE marked `Exiting` during `BeginModeExit` (via
`MarkExitingForModeEnd`). However, `ReapExiting`'s persistent-skip
remains correct because the entire runtime instance is dropped at
mode swap (chunk 8 D114's `executePendingSwap` calls `exitMode`
which closes the ModeStore session and the per-mode `Runtime`
becomes unreachable; Go GC handles release). No detach is needed
for persistent nodes — they vanish with the runtime. ReapExiting
in the mode-end window only reaps transient nodes (orphaned
in-flight flows that were already exiting before BeginModeExit
fired).

Implementation:

```
snapshot := tm.AllNodes()  // chunk 6 D82 insertion-order slice
for each FlowNode n in snapshot:
    if n.Persistent:
        continue
    if !n.Exiting:
        continue
    wrapperID = JoinNodeID(tm.rootSchemaID, n.InstanceID, "lifecycle")
    if animator.AreKeysComplete(wrapperID, n.ExitKeys):
        tm.DetachExiting(n)  // D110 — skips HandleDetach
```

Iteration order: deterministic per chunk 6 D82 (insertion-order
slice). DetachExiting during iteration is safe because the snapshot
is taken before mutation; the snapshot is an own-slice copy, so
detaches don't perturb iteration. The underlying ordered slice
maintained by chunk 6 D82 is mutated by DetachExiting, but the
loop iterates the snapshot, not the live slice.

#### D107. Phase 8 wired into `Runtime.HandleFrame`

Chunk 6 §9.3.1 D79's HandleFrame pseudocode has Phase 8 as SKIP
with the comment "exit lifecycle is chunk 7". Chunk 7 fills it:

```
// Phase 6: Render. (chunk 3 D39, chunk 6 D95)
rendered, err := r.tm.Render(ctx.Animator, ctx.Style, stepViews)
...

// Phase 6.5: retry deferred initial focus (chunk 6).
retryDeferredFocus(r.tm, rendered.ScreenUI)

// Phase 7: Apply animated values.
//   SKIP — engine drives Apply on FrameOutput.ScreenUI/WorldUI
//   after HandleFrame returns.

// Phase 8 (chunk 7 wires this): Reap exiting nodes.
r.tm.ReapExiting(ctx.Animator)

// Phase 9: Sweep orphaned animator entries.
//   SKIP — engine drives Sweep on FrameOutput.ScreenUI/WorldUI
//   after HandleFrame returns.

// Compose FrameOutput.Focused and return. (chunk 6)
```

Phase ordering rationale: per
`docs/ui_animation_system_spec.md` §12, Reap runs after Apply.
Engine drives Apply post-HandleFrame, so chunk 7's Reap inside
HandleFrame is one frame before its own Apply. The lag is:

- Frame N: Render builds `rendered.ScreenUI` containing the
  exiting node's wrapper subtree. Phase 8's `ReapExiting`
  detects keys-complete and calls `tm.DetachExiting(node)`,
  removing the FlowNode from the tree. **The already-built
  `rendered.ScreenUI` is not mutated by ReapExiting** — it
  still contains the wrapper. HandleFrame returns this
  `rendered.ScreenUI` as `FrameOutput.ScreenUI`. The engine's
  post-HandleFrame Apply uses this tree to render the terminal
  frame of the exit animation. The engine's post-HandleFrame
  Sweep walks this tree and finds the wrapper as live — entries
  are NOT cleaned up this frame.
- Frame N+1: Render rebuilds the tree without the (now detached)
  FlowNode → wrapper is absent from the rebuilt
  `rendered.ScreenUI`. The engine's post-HandleFrame Sweep on
  this rebuilt tree finds the wrapper ID absent and removes its
  animator entries.

So wrapper cleanup lands two frames after key-complete
(detected on frame N, swept on frame N+1's post-HandleFrame
Sweep). This is intentional and matches the design doc's
"node removed next frame" semantics. No attempt at tighter
zero-frame-lag detach — that would require either a second
Render pass within HandleFrame or restructuring the engine
surface, neither warranted.

`Runtime.tm` is the runtime's `*tree.TreeManager` field (chunk 6
D79). `ctx.Animator` is the `pipeline.FrameContext.Animator`
field (chunk 6 D77). No new engine surface change in chunk 7.

#### D108. `complete` / `cancel` directives → MarkExiting (transient only)

Chunk 4 D50 / D51's `complete` and `cancel` directive handlers
currently call `tm.Detach(node)` synchronously after executing
the on_complete / on_cancel function. Chunk 7 amends:

> For **transient** flow nodes, `complete` and `cancel` call
> `tm.MarkExiting(node)` instead of `tm.Detach(node)`. The node
> persists in the tree with `Exiting = true` until D106's
> `ReapExiting` detaches it on a subsequent frame.
>
> For **persistent interactive** flow nodes, `complete` /
> `cancel` reset the FlowState to `initial_step` per chunk 4 D53
> — unchanged. Persistent nodes never exit-and-detach in their
> mode's lifetime; they only reset.

The directive handler in `interpreter.Interpreter` (chunk 4) does
not directly call `tm.MarkExiting` — that's a tree concern, not
an interpreter concern. The interpreter returns its outcome
(`directive == DirectiveComplete` / `DirectiveCancel`); the
runtime's `dispatchOutcomes` in `Runtime.HandleFrame` (chunk 6
D79) calls `tm.MarkExiting` for transient nodes whose flow
returned a complete/cancel directive.

This keeps chunk 4's interpreter purity intact (no tree
mutations from interpreter code) while wiring the new behavior.

Chunk 6's existing transient-detach code path in
`dispatchOutcomes` (which currently calls `tm.Detach(node)`)
swaps to `tm.MarkExiting(node)`. The existing code path was
introduced in chunk 6 D79's command dispatch loop; chunk 7
amends that single call site.

#### D109. `battle_root` component authored — closes chunk 3 D42 / chunk 6 placeholder

Chunk 3 D42 + chunk 6 keep the `battle_root` mode root as a
placeholder (the synthetic ScreenUI root from chunk 3 D43
composes party_status as a top-level child). Chunk 7 authors a
real `battle_root` component:

```
game/assets/scripts/game/ui/components/battle_root.lua
```

Component shape (minimum viable):

```lua
local primitives = require("script.game.ui.primitives")

local M = {}

function M.Render(view_state, scope)
    return primitives.panel{
        id = "root",
        layout = primitives.fill(),
        children = {
            -- party_status renders as a top-anchored child,
            -- composed by the runtime per chunk 3 composition.
        },
    }
end

return M
```

Chunk 3 D42 places the mode root in the chunk-3 placeholder
branch ("does NOT render the mode root itself"). Chunk 7 fills
the gap: when `compReg.HasFlowNodeComponent("battle_root")`
returns true (which it now does), `TreeManager.Render` evaluates
the component for the mode root and uses the result as the
ScreenUI root structure. The translated component output replaces
the synthetic anchored panel chunk 3 D43 introduced.

**D108b. Child composition algorithm.** When a mode root has a
real component, child flow node outputs are appended as
additional children of the rendered mode-root output's root
panel. Chunk 7 locks the algorithm:

```
rendered_root_node = bridge.Translate(battle_root.Render(view_state, scope))
// rendered_root_node is a single core.UINode, typically a Panel.

for each top-level child flow node (transient + persistent) of mode root, in tree order:
    child_node = render_flow_node_with_wrapper(child)  // produces wrapped UINode
    rendered_root_node.Children = append(rendered_root_node.Children, child_node)

ScreenUI = rendered_root_node
```

Authoring contract for `battle_root.lua`: the returned panel's
`children` list **must be empty**. Authors don't pre-populate
children — the runtime appends them. If `battle_root.lua` returns
a panel with non-empty children, chunk 7's `TreeManager.Render`
returns a validation error (`opModeRootComponentChildrenForbidden`)
the first frame the schema is loaded; the load-time validation
in chunk 1 doesn't check this because chunk 1 doesn't evaluate
the component.

Anchor-aware composition (chunk 3 D43): the children are
classified by `Anchor` per chunk 3 D43 — anchored-to-screen
children go to ScreenUI, anchored-to-world children go to
WorldUI. Chunk 7 keeps this split: a mode-root component
authored at `battle_root.lua` builds the **screen** root; a
separate per-mode WorldUI root falls back to the chunk 3 D43
synthetic root (no `battle_root_world.lua` component author in
chunk 7 scope; world-anchored content can still be appended to
the synthetic WorldUI root).

**Componentless mode roots (fallback).** For schemas without
a mode-root component (chunk 1's `title_root` and
`overworld_root` may remain componentless if chunk 7's content
scope doesn't author them), the chunk 3 D43 synthetic root
persists as a fallback. The fallback's child composition is
unchanged from chunk 3.

Mode-root wrapper: per D98, the mode root is NOT wrapped (it's
the tree's outermost node; lifecycle on the mode root would mean
"animate the entire mode in/out" which is a transition concern
deferred to chunk 8).

**Authored content scope:** chunk 7 authors `battle_root.lua`
only. `title_root` and `overworld_root` components are deferred
to whichever chunk flips their cutover flag (chunk 8 or beyond).
Chunk 6 D78 keeps title/overworld with `UseTreeRuntime = false`,
so their flow tree path is dormant — no rendering pressure.

The battle proof (chunk 6 D91) still passes after chunk 7
because party_status renders against `battle_root`'s component
output the same way it did against the synthetic root: as a
top-level child of the rendered tree (D108b's composition rule
applies — children appended to the rendered root).

#### D110. Exiting-node exclusions — focus, input, routing, policy

`docs/ui_animation_system_spec.md` §10 specifies that exiting
nodes are "rendered with frozen state but have no focus, no
input, no policy". Chunk 7 D103 covered the frozen-derivation
half. This decision covers the rest:

**1. Render-side: `focusable = false` on emitted UINodes.**
When `TreeManager.Render` walks a flow node whose `Exiting` is
true, every emitted UINode (including the wrapper from D96 and
all children of the component output) gets its
`Focus.Focusable` field set to false in post-translation
post-processing. This excludes the entire exiting subtree from
the engine's compiled focus graph, so D-pad navigation and
spatial queries cannot land in fading-out content.

Implementation: the wrapper-insertion step in `TreeManager.Render`
(D98) checks the flow node's `Exiting` flag; if set, it walks
the wrapper subtree and zeros `Focusable` on each node
(`n.Focus.Focusable = false`). This is one extra walk per
exiting flow node per frame — negligible cost; exiting subtrees
are short-lived.

**2. Routing-side: `routing.HandleExitStart` for focus handoff.**

```go
// HandleExitStart transfers focus away from the exiting node's
// subtree, if focus is currently within it. Mirrors HandleDetach
// (chunk 5 D71) but fires at exit-start rather than at detach.
//
// If the focused instance_id is the exiting node OR a descendant
// of it (descendant-of relation per chunk 2's tree structure),
// HandleExitStart calls the same "find next focus owner"
// fallback as HandleDetach (parent's focus_entry, then mode
// root's restoration policy). If focus is already elsewhere,
// HandleExitStart is a no-op.
//
// Returns the same error shape as HandleDetach.
func HandleExitStart(tm *tree.TreeManager, exitingNode *tree.FlowNode) error
```

`MarkExiting` calls `HandleExitStart` (D102 step 6). The
distinction from `HandleDetach`: `HandleExitStart` fires once,
at the moment of state transition; `HandleDetach` fires once,
at the moment of tree detach. For an exiting node:

- `HandleExitStart` runs at MarkExiting time (frame N) — focus
  moves out.
- `ReapExiting` calls `tm.DetachExiting(node)` (frame N+K) —
  which is the chunk-7 split variant that does NOT call
  `HandleDetach` because focus was already handed off at
  exit-start. Chunk 7 amends chunk 2 D29 / chunk 5 D71's
  invariant: `Detach` only fires `HandleDetach` for direct
  (non-exit-driven) detach paths.

Locked: `tm.Detach` is split into:

```go
// Detach removes a node and fires HandleDetach for focus
// restoration. Used by direct (non-exit-driven) detach paths.
func (tm *TreeManager) Detach(node *FlowNode) error

// DetachExiting removes a node without firing HandleDetach. The
// caller (ReapExiting) has already handed off focus via
// HandleExitStart at MarkExiting time.
func (tm *TreeManager) DetachExiting(node *FlowNode) error
```

`ReapExiting` calls `DetachExiting`. All other detach call sites
(chunk 4 directives that bypass exit, future chunk 8 mode-end
cleanup) call `Detach`.

Chunk 4's transient-node `complete` / `cancel` directive
handler in chunk 6's `dispatchOutcomes` (D108) currently calls
`tm.Detach`; chunk 7 changes it to `tm.MarkExiting`. So `Detach`
itself is no longer called from the directive path — only from
chunks that bypass the exit phase entirely (none in chunk 7;
chunk 8 may call it for mode-end shutdown).

**3. Routing-side: input routing skips exiting nodes.**
`routing.RouteInput` (chunk 5 D68) walks the tree to find the
input owner. Chunk 7 amends: nodes with `Exiting == true` are
skipped during this walk. If the only candidate owner is an
exiting node (e.g., a transient flow node fading out with no
other candidate), routing returns no-owner and the input is
unconsumed (passes to the engine as it would for any unowned
input). This matches the design contract that exiting nodes
have no input.

Implementation: the `RouteInput` walk's per-node candidate check
adds `if node.Exiting { continue }` at the top of the loop.

**4. Policy-side: aggregations skip exiting nodes.**
Chunk 6 D84 (pause), D85 (save), D86 (input/open suppression)
all aggregate over the visible flow tree. Chunk 7 amends each:
nodes with `Exiting == true` are skipped during aggregation.

Concrete amendments:
- `policy.ResolvePausePolicy(tm)` walks all nodes via
  `tm.AllNodes()` (chunk 6 D82); chunk 7 adds `if n.Exiting:
  continue` at the top of the iteration body.
- `policy.CanSave(tm)` — same skip.
- `policy.InputSuppressed(tm)` / `OpenSuppressed(tm)` — same skip.

The exit phase is therefore "policy-neutral" for the exiting
node: its `pause_request`, `save_policy`, and `input_suppressed`
fields are ignored from MarkExiting until detach. This prevents
a fading-out pause menu from holding the game paused while it
fades away.

**5. Visibility-side: exiting nodes ARE rendered.**
Per design spec §10, exiting nodes remain in the rendered tree
(they render with frozen view_state). Chunk 7 does NOT skip
them in `Render`. The wrapper UINode and the (frozen) component
output appear in `screenUI` / `worldUI` exactly as a non-exiting
node would, with the only differences being:
- `Focus.Focusable = false` on every node (per #1 above).
- `Visible` flag on the FlowNode is unchanged by exiting state
  (chunk 6 D87's "no-op" rule still holds; visibility is not
  driven by exiting).

These five exclusions are the operational meaning of "exiting
state": rendered, but inert for focus/input/policy purposes.

### 10.2 Chunk 7 acceptance

#### 10.2.1 New files

```
game/ui/animlua/
  types.go         — Entry, ParseMode
  parser.go        — ParseEntries
  errors.go        — op constants (opAnimLuaFieldType, etc., lifted from
                     game/ui/components/animations.go's existing op set)
  parser_test.go

game/assets/scripts/game/ui/components/
  battle_root.lua

game/interaction/runtime/
  exit_lifecycle_test.go    — MarkExiting → ReapExiting end-to-end
                              against a unit-test FlowNode + animator
```

#### 10.2.2 Modified files

```
game/interaction/schema/
  types.go        — Schema gains EnterAnimations, EnterSequences,
                    ExitAnimations, ExitSequences (D100)
  extract.go      — replace local anim extractor with animlua.ParseEntries call
  validate.go     — relax target-rejection (now handled by parser's
                    ParseModeSchemaLifecycle); add validation that
                    enter/exit anims have unique keys per surface
  registry_test.go, validate_test.go, extract_test.go — sequence-form parsing
                    tests added; existing single-form tests adjust for new
                    storage shape
  errors.go       — op constants pruned where animlua now owns them

game/interaction/tree/
  flow_node.go (or wherever chunk 2 D25 places it) — adds NeedsEnterAnimations,
                    Exiting, ExitKeys, FrozenViewState fields
  tree_manager.go — adds MarkExiting, ReapExiting; UpdateViewStates skip
                    on exiting nodes (D103); persistent passive flag-set
                    in NewTreeManager; chunk 6 D82 AllNodes iteration usage
  tree_manager_test.go — coverage for new methods + skip-on-exiting

game/interaction/runtime/
  runtime.go      — NeedsEnterAnimations flag-set in NewRuntime (persistent
                    interactive nodes); Phase 8 wired into HandleFrame;
                    dispatchOutcomes routes complete/cancel for transient
                    nodes through tm.MarkExiting + r.startExitAnimations
                    (D108); startExitAnimations method added (D102)
  runtime_test.go — exit lifecycle integration test

game/interaction/interpreter/
  attach.go (or chunk 4 equivalent) — Attach / AttachTo set
                    NeedsEnterAnimations when schema has enter anims (D101)
  attach_test.go

game/interaction/routing/
  routing.go      — adds HandleExitStart (D110); RouteInput skips exiting
                    nodes during owner search (D110)
  routing_test.go — coverage for HandleExitStart focus handoff and
                    exiting-node skip in RouteInput

game/interaction/policy/
  pause.go        — ResolvePausePolicy skips exiting nodes (D110)
  save.go         — CanSave skips exiting nodes (D110)
  suppression.go  — InputSuppressed / OpenSuppressed skip exiting nodes (D110)
  policy_test.go  — coverage for skip-on-exiting in each aggregator

game/ui/components/animations.go
  — reduces to thin wrapper: animlua.ParseEntries +
    resolveAnimTargets. resolveAnimTargets retained as-is.
animations_test.go — tests that exercise animlua surface migrate; tests
                    that exercise resolveAnimTargets stay.

internal/importguard/import_guard_test.go
  — add game/ui/animlua rule (importable by game/interaction/schema and
    game/ui/components; forbidden from game/interaction/runtime,
    game/interaction/tree, all engine packages except engine/ui/anim,
    engine/ui/anim/easing, engine/ui/style, gopher-lua, failures)
  — game/interaction/schema rule: add game/ui/animlua to allowed imports
  — game/ui/components rule: animlua already implicit if components imports
    a game/ui sibling; verify

game/assets/catalog.json
  — regenerated by tools/cataloggen/ to include battle_root.lua

engine/ui/anim/animator.go
  — exports ValidateAnimation(key, a) and ValidateSequence(key, s)
    as thin wrappers over the existing private validators (D99).
    No behavior change; engine surface gains two new public funcs.

engine/presentation/render_compile.go (or wherever layout-compile lives)
  — adds NodeKindUnknown single-child passthrough rule (D97). The
    wrapper UINode's compiled rect equals its single child's
    compiled rect. Required, not optional.

game/interaction/runtime/
  runtime.go — also adds startExitAnimations method (D102) and
              D110's HandleExitStart integration in the dispatchOutcomes
              complete/cancel path
```

#### 10.2.3 Test matrix

`animlua/parser_test.go`:
- Single-form Animation parses correctly (one each of opacity,
  offset_x/y, scale, background/text/border/outline color).
- Sequence-form parses correctly (≥2 keyframes, varying easing
  per segment, looping).
- `ParseModeSchemaLifecycle` rejects entries with `target` field
  (`opAnimLuaTargetNotAllowed`).
- `ParseModeComponentDeclared` rejects entries without `target`
  (`opAnimLuaTargetRequired`).
- Sparse-array detection (existing `animations.go` logic
  preserved).
- RGBA-table form for color properties; non-table for color
  rejected.
- Number form for float properties; table for float rejected.
- Duration ≤ 0 rejected.
- Delay < 0 rejected.
- Unknown property name rejected.
- Unknown easing name rejected.
- Unknown mode name rejected.
- Sequence with < 2 keyframes rejected.
- Sequence segment duration ≤ 0 (after first) rejected.
- Multiple errors in same table aggregated via `errors.Join`.
- **Duplicate Key within one parsed list rejected**
  (`opAnimLuaKeyDuplicate`) — both for `ParseModeComponentDeclared`
  and `ParseModeSchemaLifecycle`.
- **Duplicate Property within one parsed list rejected**
  (`opAnimLuaPropertyDuplicate`) — same property declared twice
  under different keys in one list. (Cross-list singles+sequences
  on the same property is NOT validated; it's authoring guidance
  per D101.)
- `anim.ValidateAnimation` and `anim.ValidateSequence` exported
  surfaces are reachable: parser produces an `anim.Animation`
  / `anim.Sequence`, validates via the exported entry points,
  rejects on bad spec.

`game/interaction/schema/validate_test.go` (additions):
- `enter_animations` parses single + sequence entries into the
  D100 Schema fields.
- `exit_animations` parses single + sequence into the D100 Schema
  fields.
- Schema with `enter_animations[*].target = "..."` rejected with
  `opSchemaAnimationTargetNotAllowed` (chunk 1 D20 op preserved
  for compatibility; animlua emits `opAnimLuaTargetNotAllowed`
  internally and `extract.go` wraps/translates that into the
  schema-layer op per D100's two-op sandwich).
- Duplicate key within `enter_animations` rejected.
- Duplicate key within `exit_animations` rejected.
- Same key across enter and exit accepted (separate surfaces).

`game/interaction/tree/tree_manager_test.go` (additions):
- `MarkExiting` sets `Exiting`, copies view_state into
  `FrozenViewState`, populates `ExitKeys` from schema.
- `MarkExiting` calls `routing.HandleExitStart` (verify focus
  handoff via test double or routing-test integration).
- `MarkExiting` does NOT touch the animator (state-only per
  D102 split).
- `MarkExiting` on a node with no exit_animations leaves
  `ExitKeys` empty.
- `ReapExiting` detaches a node whose ExitKeys are complete.
  Verifies `DetachExiting` is used (HandleDetach NOT called).
- `ReapExiting` does NOT detach a node whose ExitKeys are not
  yet complete.
- `ReapExiting` with empty `ExitKeys` detaches immediately.
- `ReapExiting` is safe under iteration (snapshot-iterate).
- `UpdateViewStates` skips exiting nodes (no derivation called;
  `ViewState` unchanged from frozen reference).
- `Detach` (non-exiting variant) calls `HandleDetach` per chunk 5
  D71 — unchanged from chunk 5.

`game/interaction/runtime/runtime_test.go` (additions):
- `startExitAnimations` calls `animator.StartGroup` /
  `StartSequenceGroup` against wrapper ID = `JoinNodeID(...,
  "lifecycle")` for non-empty exit lists.
- `startExitAnimations` returns first error if a group call
  fails; runtime reports via errorSink.
- `dispatchOutcomes` complete/cancel for transient nodes calls
  `tm.MarkExiting` then `r.startExitAnimations` in sequence.

`game/interaction/runtime/exit_lifecycle_test.go`:
- Open transient flow → run a frame → directive returns
  `complete` → next frame's HandleFrame transitions node to
  exiting → animator has exit animations running → frame N+K
  (after exit_animations complete) → Phase 8 detaches.
- Open transient flow → directive returns `cancel` → same.
- Open transient flow with no exit_animations → `complete` →
  next frame Phase 8 detaches immediately.

`game/interaction/runtime/runtime_test.go` (additions):
- `NewRuntime` sets `NeedsEnterAnimations` on persistent
  interactive nodes whose schema has enter animations; does NOT
  set on those without.
- `TreeManager.Render` consumes `NeedsEnterAnimations` flag,
  starts enter animations, and clears the flag.
- Re-render of the same node does NOT re-start enter animations
  (flag already cleared).
- `MarkExiting` is idempotent: calling twice on the same node
  in successive frames does not re-snapshot view_state, does
  not re-call HandleExitStart, does not restart elapsed time
  on already-running exit animations.
- `MarkExiting` panics when called on a persistent node (Bucket
  A; verified via `recover()` in the test).

`game/interaction/routing/routing_test.go` (D110 additions):
- `HandleExitStart` transfers focus when the focused node is the
  exiting node.
- `HandleExitStart` transfers focus when the focused node is a
  descendant of the exiting node.
- `HandleExitStart` is a no-op when focus is elsewhere.
- `RouteInput` skips exiting nodes during owner search; if the
  only candidate owner is exiting, `RouteInput` returns
  no-owner and the input passes unconsumed.

`game/interaction/policy/policy_test.go` (D110 additions):
- `ResolvePausePolicy` ignores `pause_request` from exiting
  nodes (test: an exiting node with `pause_request = always`
  does not pause the game).
- `CanSave` ignores save policy from exiting nodes.
- `InputSuppressed` / `OpenSuppressed` ignore suppression flags
  from exiting nodes.

`game/interaction/tree/tree_manager_test.go` (D110 additions):
- `Render` post-processes exiting subtrees: every UINode under
  an exiting flow node has `Focus.Focusable = false`.
- `Detach` calls `HandleDetach`; `DetachExiting` does not.

`engine/ui/anim/animator_test.go` (D99 additions):
- Exported `ValidateAnimation(key, a)` returns the same errors
  as `Start(nodeID, key, a)` for invalid specs (mirror existing
  validation tests; no behavior change for `Start`).
- Exported `ValidateSequence(key, s)` returns the same errors as
  `StartSequence`.

`engine/presentation/render_compile_test.go` (D97 additions):
- `NodeKindUnknown` with one child compiles to the child's rect
  (passthrough); the wrapper emits no draw ops.
- Scale on the wrapper scales the child around the child's rect
  center (verified via §7 propagation rule).

`game/ui/components/animations_test.go` (preserved + adjusted):
- All existing tests pass after migration to animlua. The
  resolveAnimTargets path is unchanged.

`internal/importguard/import_guard_test.go`:
- New `game/ui/animlua` rule passes (allowed/forbidden checked).
- `game/interaction/schema` import of `game/ui/animlua` is now
  allowed (rule update verified).

#### 10.2.4 Integration proof

The chunk 6 battle proof (party_status command_panel →
`battle_action` command) is re-run with chunk 7 changes in place.
Acceptance: same battle proof passes, plus:

1. Open pause_menu (transient) via input.
2. Pause menu's enter_animations play (visible via animator
   `IsAnimating(wrapperID)` returning true on the frame after
   attach).
3. Player cancels pause_menu (B button or equivalent).
4. Pause menu transitions to exiting (Exiting flag set; ExitKeys
   populated).
5. Exit animations play; `IsAnimating(wrapperID)` true during
   exit phase.
6. After exit_animations complete (`AreKeysComplete(wrapperID,
   ExitKeys) == true`), the next frame's Phase 8 detaches the
   node.
7. Subsequent frames render without pause_menu.

This proof is asserted in `runtime_test.go` via a
deterministic-clock test that advances `UIDeltaSeconds` past
the configured exit duration.

For chunk 7 to pass, the schema for `pause_menu.lua` must have
both `enter_animations` and `exit_animations` declared. Chunk 1
authored `pause_menu.lua` per design spec §11.1; chunk 7's
implementer verifies the authored schema includes both surfaces
(if the design spec template doesn't yet include enter/exit on
pause_menu, chunk 7 adds them — this is small content authoring,
within scope).

#### 10.2.5 Validation gate

```
go run ./tools/cataloggen/ -check
go test ./engine/ui/anim/...
go test ./engine/presentation/...
go test ./game/ui/animlua/...
go test ./game/interaction/schema/...
go test ./game/interaction/tree/...
go test ./game/interaction/routing/...
go test ./game/interaction/policy/...
go test ./game/interaction/runtime/...
go test ./game/ui/components/...
go test -tags guard ./internal/importguard/...
go run ./tools/devctl testall
```

All twelve must pass.

### 10.3 Chunk 7 implementation notes

#### 10.3.1 Chunk-ordering implication for animlua

D99's `animlua` package is depended on by chunk 1's schema
extractor (D100 storage shape). Chunk 1 cannot fully ship until
animlua exists. Two options for the chunk-1 prompt sequencing:

- **Option A — chunk 1 ships with local extractor; chunk 7
  migrates.** Chunk 1's prompt builds a temporary local extractor
  (the path called out in chunk 1 §4.3.2's "may duplicate Phase 4
  logic"), and chunk 7's prompt explicitly migrates chunk 1's
  schema package onto animlua + drops the local extractor. This
  is Otto's locked plan per the chunk-1 spec.
- **Option B — animlua ships before chunk 1.** Reorder so animlua
  is its own micro-chunk between chunks 0 and 1 of implementation
  (specs are still chunked 1-8; only the prompt sequence reorders).

Chunk 7 spec assumes **Option A**. The chunk 1 prompt will
produce a self-contained chunk 1 deliverable; chunk 7's prompt
will execute the migration to animlua + the remainder of chunk 7
work. This preserves chunk independence — chunk 1's tests pass
on a tree without animlua, chunk 7's tests pass on a tree with
animlua.

#### 10.3.2 Wrapper layout-compile patch (mandatory)

Per D97, the wrapper UINode uses `NodeKindUnknown` and the
implementer adds the single-child passthrough rule to the
layout-compile path. The existing code does NOT special-case
`NodeKindUnknown` as a passthrough; it lays it out as an
ordinary node (typically zero-rect since no Panel/Text/Layout
backing). Without the patch, lifecycle Scale would scale around
a zero-rect wrapper instead of the child's rect.

Implementation:

1. Read the layout-compile entry point (likely
   `engine/presentation/render_compile.go`) and locate the
   per-node compile branch.
2. Add a `NodeKindUnknown` arm: when `len(node.Children) == 1`,
   compile the child and copy its compiled rect to the wrapper's
   output. The wrapper emits no draw ops (existing behavior).
   Multi-child unknown nodes (not produced by chunk 7 — wrappers
   always have exactly one child) fall through to the existing
   "no draw ops, no contribution" behavior.
3. Verify Scale animations on the wrapper produce visually
   correct output: the wrapper is the animator target (Apply
   writes Scale onto the wrapper's `Scale` field), and the §7
   propagation rule in `docs/ui_animation_system_spec.md`
   compiles scale around the wrapper's rect center — which
   under the patch equals the child's rect center.

The animator's Apply (`engine/ui/anim/animator.go:504`)
walks the tree by NodeID and writes Opacity/OffsetPx/Scale to
matching nodes. The walk is kind-agnostic; the wrapper is found
by ID and animated. The §7 propagation of OffsetPx/Scale to
children happens at compile/draw time, not Apply time, so the
compile patch is the necessary engine-side change for chunk 7.

Tests for this patch live alongside existing layout-compile
tests; the chunk 7 acceptance gate includes them.

#### 10.3.3 Animator Reconcile for component-declared animations is unchanged

Chunk 3 D39 already established: `TreeManager.Render` calls
`animator.Reconcile(na.NodeID, na.Declared)` for each
`NodeAnimations` group in `out.Animations`. Chunk 7 does not
modify this loop. Component-declared animations continue to
target IDs derived from `ComponentOutput.Node.ID` (resolved by
`game/ui/components/animations.go`'s `resolveAnimTargets`). The
wrapper at `JoinNodeID(rootSchemaID, instance_id, "lifecycle")`
is a separate, additive identity; component-declared targets
never resolve to it.

#### 10.3.4 Persistent passive nodes get enter animations too

Chunk 1's passive profile allows `enter_animations` and
`exit_animations` (chunk 1 D20 applies to all schemas
uniformly). Chunk 7's `NewTreeManager` constructor sets
`NeedsEnterAnimations` on persistent passive nodes whose schema
declares enter animations, in the same way chunk 6's `NewRuntime`
does for persistent interactive. Persistent passive nodes never
enter exiting state because they have no `complete` / `cancel`
directives — only attach (mode start) and detach (mode end).
Their exit animations, if declared, never play. This is
documented as authoring guidance: declare exit animations on
passive nodes only if you anticipate future
mode-transition-driven exit (chunk 8 work) will play them.

#### 10.3.5 Layer guard rule for `game/ui/animlua`

```go
{
    ImporterBase: modulePath + "/game/ui/animlua",
    ForbiddenBase: []string{
        modulePath + "/engine/runtime",
        modulePath + "/engine/pipeline",
        modulePath + "/engine/ui/core",      // animlua is parser-only, no NodeID
        modulePath + "/engine/ui/query",
        modulePath + "/engine/sim",
        modulePath + "/engine/world",
        modulePath + "/engine/presentation",
        modulePath + "/engine/overlay",
        modulePath + "/engine/text",
        modulePath + "/engine/input",
        modulePath + "/engine/display",
        modulePath + "/engine/animation",
        modulePath + "/engine/session",
        modulePath + "/engine/command",
        modulePath + "/engine/errorsink",
        modulePath + "/engine/clock",
        modulePath + "/engine/ds",
        modulePath + "/engine/fs",
        modulePath + "/engine/ecs",
        modulePath + "/engine/script",
        modulePath + "/engine/coretypes",
        modulePath + "/engine/assets",
        modulePath + "/game/menu",
        modulePath + "/game/flow",
        modulePath + "/game/ui/bridge",
        modulePath + "/game/ui/components",
        modulePath + "/game/ui/derivation",
        modulePath + "/game/ui/primitives",
        modulePath + "/game/defs",
        modulePath + "/game/mode",
        modulePath + "/game/presentation",
        modulePath + "/game/proposals",
        modulePath + "/game/snapshot",
        modulePath + "/game/uidef",
        modulePath + "/game/worldstreaming",
        modulePath + "/game/interaction",   // not a leaf, but animlua sits below
        modulePath + "/render",
        modulePath + "/platform",
    },
    ExactImport: []string{
        modulePath + "/game",
    },
    Rule:        "layers.md: game/ui/animlua may import engine/ui/anim, engine/ui/anim/easing, engine/ui/style, failures, and gopher-lua only",
    Remediation: "keep animlua as a leaf parser; resolution and target validation belong in callers",
}
```

`game/interaction/schema`'s rule is amended to add
`modulePath + "/game/ui/animlua"` to its allowed imports.
`game/ui/components`'s existing rule (Phase 4) already permits
`game/ui` siblings.

#### 10.3.6 Surface-change footprint

Engine surface changes (small + additive):
- `engine/ui/anim/animator.go` — exports `ValidateAnimation(key,
  Animation) error` and `ValidateSequence(key, Sequence) error`
  as thin wrappers over existing private validators (D99). No
  behavior change for existing callers.
- `engine/presentation/render_compile.go` (or layout-compile
  equivalent) — adds the `NodeKindUnknown` single-child
  passthrough rule (D97 / §10.3.2). Required for wrapper Scale
  semantics.

Tree-package additions:
- `tree.FlowNode.NeedsEnterAnimations`, `Exiting`, `ExitKeys`,
  `FrozenViewState` fields (D104).
- `tree.TreeManager.MarkExiting` (state-only, D102) and
  `ReapExiting` (D106) methods.
- `tree.TreeManager.Detach` split into `Detach` +
  `DetachExiting` (D110) — `DetachExiting` skips
  `HandleDetach` because focus was already handed off at
  `MarkExiting` time.
- `UpdateViewStates` behavior amended to skip exiting nodes
  (D103).
- `tree.TreeManager.Render` retrofit: wrapper insertion +
  exiting-subtree `Focus.Focusable = false` post-processing
  (D98 / D110).

Runtime-package additions:
- `Runtime.HandleFrame` Phase 8 wiring (D107).
- `Runtime.startExitAnimations` method (D102) — runtime owns
  animator group-call orchestration for exit phase.
- `dispatchOutcomes` complete/cancel routing via `MarkExiting`
  + `startExitAnimations` for transient nodes (D108).
- `NewRuntime` sets `NeedsEnterAnimations` on persistent
  interactive nodes (D101).

Routing-package additions (chunk 5 retrofit):
- `routing.HandleExitStart` (D110) — focus handoff at exit start,
  mirroring `HandleDetach` but earlier in the lifecycle.
- `routing.RouteInput` skips exiting nodes during owner search
  (D110).

Policy-package additions (chunk 6 retrofit):
- `policy.ResolvePausePolicy`, `policy.CanSave`,
  `policy.InputSuppressed`, `policy.OpenSuppressed` all skip
  exiting nodes (D110).

Schema-package additions:
- `Schema.EnterAnimations`, `EnterSequences`, `ExitAnimations`,
  `ExitSequences` typed as `[]LifecycleAnimation` /
  `[]LifecycleSequence` (D100).
- `LifecycleAnimation` and `LifecycleSequence` types
  (key + spec pairs) declared in schema package.
- `extract.go` migrates to animlua.

Components-package change:
- `game/ui/components/animations.go` migrates to animlua;
  `resolveAnimTargets` retained.

New leaf package:
- `game/ui/animlua/` — types-only parser, single + sequence
  forms, ParseMode discriminator (D99).

Authored content:
- `game/assets/scripts/game/ui/components/battle_root.lua` (D109).
- `enter_animations` / `exit_animations` blocks added to
  `pause_menu.lua` for the integration proof (chunk 1's authored
  pause_menu may already include these per design spec §11.1; if
  not, chunk 7 adds them).

---

## 11. Chunk 8 — Phase 6 Closure

**Status:** Draft, pre-implementation.
**Goal:** Close Phase 6. Wire the chunk-6-deferred dead-letter
behaviors into live host routing; implement async mode
transitions with mode-root + persistent-passive lifecycle anims;
implement cue-driven `open_flow`; reshape the `ModeSession`
interface for the new runtime; flip title + overworld cutover
to ON; delete `game/menu/`, `game/flow/`, the bridging adapters,
and the per-mode `UseTreeRuntime` flag itself; retrofit the F5
quicksave gate to use the new save-policy aggregation.

After chunk 8 lands, the new runtime is the only path. The
legacy menu/flow code is gone. Mode transitions play exit
animations on the outgoing mode and enter animations on the
incoming mode. Quit plays the outgoing mode's exit animations
before the host closes. Title navigates to overworld via a real
interactive flow. Quicksave continues to work, gated on the new
two-layer save policy.

### 11.1 Chunk 8 decisions (D111–D131)

#### D111. `HostRequestKind` enum extensions — `ModeTransition` + `Quit`

`engine/pipeline/host_effect_types.go` gains two new enum
constants:

```go
type HostRequestKind int

const (
    HostRequestSetWindowMode HostRequestKind = iota
    HostRequestOpenFlow
    HostRequestWarnNotImplemented

    // Chunk 8 additions:
    HostRequestModeTransition
    HostRequestQuit
)
```

Insertion point: at the end of the existing iota block,
preserving stable values for the existing kinds. The implementer
verifies no on-disk save format or wire protocol depends on
ordinal stability of the existing values (it doesn't —
`HostRequestKind` lives only in-memory per-frame; save format is
the ECS world payload per `docs/systems/save_load.md`, no
HostRequest data persisted).

#### D112. `HostRequest` struct fields + executor extension

`engine/pipeline/host_effect_types.go` gains the payload field:

```go
type HostRequest struct {
    Kind               HostRequestKind
    WindowMode         WindowModeSpec
    OpenFlow           FlowOpenSpec
    WarnNotImplemented string

    // Chunk 8 additions:
    ModeTransition ModeTransitionSpec
    // Quit has no payload fields — Kind alone identifies it.
}

// ModeTransitionSpec carries the integer target mode. The int type
// preserves engine-side neutrality (engine/pipeline cannot import
// game/mode); the game-side adapter converts to mode.GameMode.
//
// All values of the enum are valid targets including the zero value
// (mode.ModeTitle == 0). The adapter validates membership via
// mode.AllGameModes; the engine layer treats the int opaquely.
type ModeTransitionSpec struct {
    Target int
}
```

**Executor extension.** The kind-switch site is the existing
`engine/runtime.HostEffectExecutor`
([engine/runtime/host_effect_executor.go:38](engine/runtime/host_effect_executor.go#L38))
which currently holds a `flowControl FlowControlHooks` field for
the `HostRequestOpenFlow` route. Chunk 8 adds a second field for
the new kinds, riding the existing `engine/runtime.TransitionPolicy`
interface ([engine/runtime/host_effect_executor.go:25](engine/runtime/host_effect_executor.go#L25))
which already exists with `RequestTransition(int) error` and
`CurrentMode() int`:

```go
// engine/runtime/host_effect_executor.go (chunk 8 modified):

// TransitionPolicy gains RequestQuit per chunk 8:
type TransitionPolicy interface {
    RequestTransition(targetMode int) error  // existing
    CurrentMode() int                        // existing
    RequestQuit() error                      // chunk 8 addition
}

type HostEffectExecutor struct {
    window      windowModeApplier
    flowControl FlowControlHooks
    transition  TransitionPolicy   // chunk 8 addition
    logger      *slog.Logger
}

func NewHostEffectExecutor(
    window windowModeApplier,
    flowControl FlowControlHooks,
    transition TransitionPolicy,    // chunk 8 — new constructor arg
    logger *slog.Logger,
) (*HostEffectExecutor, error)
```

`Execute` gains two new switch cases:

```go
case pipeline.HostRequestModeTransition:
    if err := e.transition.RequestTransition(effect.ModeTransition.Target); err != nil {
        return failures.Wrap(failures.Fault, "pipeline_host_effect_mode_transition", err)
    }
case pipeline.HostRequestQuit:
    if err := e.transition.RequestQuit(); err != nil {
        return failures.Wrap(failures.Fault, "pipeline_host_effect_quit", err)
    }
```

**`engine/pipeline.RuntimeControlHooks` interface is unchanged.**
The interface continues to expose only `ResolveFramePolicy` +
`QuitRequested()` (a query method, not a request method). Quit
requests flow through `TransitionPolicy.RequestQuit`, which
ModeController implements via its existing `transitionAdapter`
extended with the new method. The `QuitRequested()` query
remains the host's mechanism to poll whether shutdown should
proceed (see D119 for how ModeController feeds it).

`game/mode_controller.go`'s existing `transitionAdapter` (the
type at [game/mode_controller.go:21](game/mode_controller.go#L21))
gains the `RequestQuit` method:

```go
func (a transitionAdapter) RequestQuit() error {
    return a.mc.RequestQuit()
}
```

so it satisfies the extended `engine/runtime.TransitionPolicy`
interface. `ModeController.TransitionPolicy()` continues to
return this adapter; chunk 8 just exposes it through the same
return path with the new method present.

The `int` target type is the existing convention (matches
`TransitionPolicy.RequestTransition`'s signature already in
place). All existing `mode.GameMode` values, including
`ModeTitle == 0`, are valid integers; the "zero is invalid"
claim from earlier scoping was wrong and is dropped.

#### D113. `dispatchOutcomes` routing — replaces chunk 6 D80 dead-letter

Chunk 6 D80's dead-letter behavior is replaced. `dispatchOutcomes`
no longer emits sentinel `opRuntimeTransitionDropped` /
`opRuntimeQuitDropped` failures. Instead:

```go
// Inside Runtime.HandleFrame's dispatchOutcomes consumption (chunk 6 D79):

if out.Transition != nil {
    hostEffects = append(hostEffects, pipeline.HostRequest{
        Kind: pipeline.HostRequestModeTransition,
        ModeTransition: pipeline.ModeTransitionSpec{Target: int(*out.Transition)},
    })
}
if out.QuitRequested {
    hostEffects = append(hostEffects, pipeline.HostRequest{
        Kind: pipeline.HostRequestQuit,
    })
}
```

The executor (D112) then routes each to the appropriate
ModeController method. Sentinel ops `opRuntimeTransitionDropped`
and `opRuntimeQuitDropped` are deleted from
`game/interaction/runtime/errors.go`.

`DispatchedOutcome.Transition` field type stays as
`*mode.GameMode` (the runtime layer naturally types
mode-as-mode); the `int` cast happens at the executor surface,
not at the runtime.

#### D114. `ModeController.TransitionPhase` state model

`game/mode_controller.go` gains:

```go
type TransitionPhase int

const (
    TransitionIdle TransitionPhase = iota
    TransitionExiting              // outgoing mode's exit anims playing
)

type ModeController struct {
    // ... existing fields ...
    pending          *mode.GameMode    // existing
    pendingQuit      bool              // chunk 8 — quit through exit phase per D119
    transitionPhase  TransitionPhase   // chunk 8
    transitionFrame  bool              // existing — set on swap-frame
}
```

`applyPendingTransition` (called per-frame from
`ResolveFramePolicy`) becomes async-aware:

```go
func (mc *ModeController) applyPendingTransition() {
    switch mc.transitionPhase {

    case TransitionIdle:
        if mc.pending == nil && !mc.pendingQuit {
            return
        }
        // Begin exit phase. Mark mode root + persistent passives as
        // exiting on the current mode's runtime (D115).
        runtime := mc.runtimeForMode(mc.current)
        if runtime != nil {
            runtime.BeginModeExit()
        }
        mc.transitionPhase = TransitionExiting
        // Stay in current mode this frame. Exit anims start playing
        // on the next render.

    case TransitionExiting:
        runtime := mc.runtimeForMode(mc.current)
        // If runtime is nil (paranoid case) or exit is complete, swap.
        if runtime == nil || runtime.IsModeExitComplete() {
            mc.executePendingSwap()
            mc.transitionPhase = TransitionIdle
        }
        // Otherwise stay in exiting state, wait next frame.
    }
}

func (mc *ModeController) executePendingSwap() {
    if mc.pendingQuit {
        mc.pendingQuit = false
        mc.pending = nil  // a stacked transition is dropped — quit wins
        mc.quitAcknowledged = true  // QuitRequested() now returns true (D119)
        return
    }
    // Mode swap (preserves existing chunk-6 sequence):
    next := *mc.pending
    mc.pending = nil
    if next == mc.current {
        return
    }
    mc.exitMode(mc.current)
    mc.registry.ApplySet(SystemSets[next])
    mc.frameRouter.SetMode(next)
    mc.current = next
    mc.transitionFrame = true
    mc.enterMode(next)
}
```

`runtimeForMode` is a new helper on ModeController that returns
the per-mode runtime instance (chunk 6 D79 stored these on
mode-state; D114 makes the access explicit). For the post-chunk-8
world where all modes use the new runtime, this is unconditional;
no fallback to legacy paths.

`applyPendingTransition` is still called from `ResolveFramePolicy`
each frame — preserves the existing chunk-6 invocation pattern.

`RequestTransition` and the new `RequestQuit` simply set their
respective flags; phase advancement is the per-frame loop's job:

```go
func (mc *ModeController) RequestTransition(next mode.GameMode) error {
    if err := mc.validateTransition(mc.current, next); err != nil {
        return err
    }
    mc.pending = &next
    return nil
}

func (mc *ModeController) RequestQuit() error {
    mc.pendingQuit = true
    return nil
}
```

If both `pending` and `pendingQuit` are set in the same frame
(rare — quit during transition, or transition during quit), quit
wins: the exit phase plays once, and `executePendingSwap` honors
quit. Documented but not test-covered as a primary case.

#### D115. Mode-exit lifecycle — state-only `BeginModeExit`, animator-side `startModeExitAnimations`

Mirrors chunk 7 D102's split between `tm.MarkExiting` (state) and
`runtime.startExitAnimations` (animator). For mode-end:

```go
// BeginModeExit transitions the runtime into mode-exit state.
// Marks the mode root and all persistent children (passive +
// interactive) as exiting per D117. Populates ExitKeys.
// Sets r.modeExiting = true and r.modeExitAnimsStarted = false.
//
// Does NOT touch the animator. The animator is not in scope for
// ModeController callers; chunk 8's HandleFrame Phase 0
// (animator-side, see startModeExitAnimations below) starts the
// exit animations on the next frame in the runtime's normal
// HandleFrame execution window.
//
// Idempotent: calling twice is a no-op (checks r.modeExiting flag).
//
// Persistent interactive nodes enter exiting state — D117's
// override of chunk 4 D53's "reset to initial step" behavior
// during mode-end. Their exit_animations play (if any) and they
// detach with the mode at swap time.
//
// Errors from MarkExitingForModeEnd / MarkModeRootExiting (which
// can return errors from routing.HandleExitStart per chunk 7
// D102 step 6) are aggregated via errors.Join and reported
// internally via r.errorSink. BeginModeExit returns no error
// because ModeController has no error-reporting path; the
// runtime owns the sink and reports directly.
func (r *Runtime) BeginModeExit()

// IsModeExitComplete returns true when all of:
//   - the mode root's exit animations have completed
//   - every persistent passive's exit animations have completed
//   - every persistent interactive's exit animations have completed
//
// If no exit animations are declared anywhere (every key set
// empty), returns true on the first call AFTER
// startModeExitAnimations has run — consistent with chunk 7
// D105's empty-ExitKeys reaping immediately, but gated on the
// modeExitAnimsStarted flag so the test harness can't observe
// premature completion before HandleFrame Phase 0 fires.
//
// Returns false until startModeExitAnimations has actually run
// at least once — IsModeExitComplete must not return true before
// the animator state has been populated. The runtime tracks
// modeExitAnimsStarted as a guard.
//
// Pre: BeginModeExit must have been called. Calling
// IsModeExitComplete before BeginModeExit panics (Bucket A).
func (r *Runtime) IsModeExitComplete() bool

// startModeExitAnimations is internal — called from HandleFrame
// Phase 0 when r.modeExiting is true and r.modeExitAnimsStarted
// is false. Iterates all marked-exiting nodes and starts their
// exit animations on the animator (StartGroup +
// StartSequenceGroup per chunk 7 D102's pattern). Sets
// modeExitAnimsStarted = true.
//
// Returns errors.Join(...) of all per-node start failures; the
// runtime reports via errorSink and continues. Failed exit anims
// just mean those nodes' ExitKeys never populate animator state,
// so AreKeysComplete returns true vacuously and they reap
// immediately.
func (r *Runtime) startModeExitAnimations(animator *anim.Animator) error
```

Implementation:

`BeginModeExit` is state-only:
1. If `r.modeExiting`: return (idempotent).
2. Initialize `var errs []error` for aggregating per-node failures.
3. Walk `r.tm.AllNodes()` (chunk 6 D82). For each node:
   - **Skip the mode root** — `if node == r.tm.Root() { continue }`.
     The mode root is handled separately in step 4 via
     `MarkModeRootExiting`, which has different state mutations
     (wrapper at the self-rooted ID, no focus handoff). Routing
     through `MarkExitingForModeEnd` would be wrong.
   - Skip non-persistent nodes (transient nodes don't participate
     in mode-end exit; they're either already in flight or
     orphaned by the mode swap).
   - Call `r.tm.MarkExitingForModeEnd(node)`. On error,
     `errs = append(errs, err)`.
4. Call `r.tm.MarkModeRootExiting()` which sets the mode root's
   Exiting flag, snapshots FrozenViewState, populates ExitKeys
   from the root schema's exit_animations. On error,
   `errs = append(errs, err)`.
5. If `len(errs) > 0`: `r.errorSink.Report(failures.Wrap(failures.Reject, opRuntimeBeginModeExitFailed, errors.Join(errs...)))`.
   Continue regardless — failures are isolated to focus-handoff
   side effects, and the state mutations from MarkExitingForModeEnd
   / MarkModeRootExiting commit even on HandleExitStart error
   (chunk 7 D102's "state mutation already committed" contract).
6. Set `r.modeExiting = true`, `r.modeExitAnimsStarted = false`.

`startModeExitAnimations` is animator-side:
1. Walk `r.tm.AllNodes()` again. For each node where
   `node.Exiting && len(node.ExitKeys) > 0`: build
   `exitAnims map[string]anim.Animation` and
   `exitSeqs map[string]anim.Sequence` from the node's schema,
   compose wrapper ID, call `animator.StartGroup(wrapperID, ...)`
   and `animator.StartSequenceGroup(...)`. Aggregate errors via
   `errors.Join`.
2. Set `r.modeExitAnimsStarted = true`.

`IsModeExitComplete` walks the marked-exiting node set. Returns
false if `!r.modeExitAnimsStarted`. Otherwise checks
`animator.AreKeysComplete(wrapperID, node.ExitKeys)` for each;
returns false on first incomplete; true if all complete.

The animator instance comes from chunk 6 D77's
`pipeline.FrameContext.Animator` field. ModeController calls
`BeginModeExit` (no animator needed) during
`applyPendingTransition`, which fires from `ResolveFramePolicy`.
Same frame, slightly later, the engine pipeline calls
`HandleFrame` with `ctx.Animator` populated; HandleFrame's
Phase 0 (described in D107's amended sequence below) calls
`startModeExitAnimations` so the animator state gets populated
before any `IsModeExitComplete` poll the next frame.

`HandleFrame` Phase 0 amendment to chunk 6 / chunk 7 ordering:

```
// Chunk 8 addition — Phase 0, before existing chunk-6 phases:
if r.modeExiting && !r.modeExitAnimsStarted {
    if err := r.startModeExitAnimations(ctx.Animator); err != nil {
        r.errorSink.Report(err)
        // continue — exit failures don't tear down the frame
    }
}

// existing chunk-6 phases continue here ...
```

#### D116. Mode-root lifecycle wrapper — extends chunk 7 D96 to mode roots

Reverses chunk 7 D98's "mode root NOT wrapped in chunk 7"
carve-out. Chunk 8 wraps the mode root the same way it wraps
non-root flow nodes:

```
wrapper ID = core.JoinNodeID(rootSchemaID, rootSchemaID, "lifecycle")
```

Per chunk 2 D26, the mode root's `InstanceID` equals the
`rootSchemaID`. So a battle-mode root (rootSchemaID =
"battle_root") has wrapper ID
`"battle_root/battle_root/lifecycle"`.

`TreeManager.Render`'s mode-root composition wraps whatever the
mode-root render produces — both branches:

- **Mode root with authored component** (chunk 7 D109 + chunk 7
  D108b): the component's translated output panel becomes the
  wrapper's single child. Children of the mode root are appended
  to the panel's children per D108b.
- **Mode root without authored component** (chunk 3 D43 / chunk
  7 D109 fallback): the synthetic anchored panel that composes
  child outputs becomes the wrapper's single child.

After chunk 8 lands:
- **Battle:** has an authored mode-root component
  (`game/assets/scripts/game/ui/components/battle_root.lua` per
  chunk 7 D109). The component-output branch applies.
- **Title and overworld:** authored as **schemas** in D125/D126
  (`game/assets/scripts/game/interaction/schemas/title_root.lua`
  and `overworld_root.lua`), but **NOT** as bridge components.
  No `game/assets/scripts/game/ui/components/title_root.lua` or
  `overworld_root.lua` is authored in chunk 8. They use the
  chunk 3 D43 synthetic-root fallback. Their wrappers wrap that
  synthetic root.

**Wrapper applies to ScreenUI mode-root only; WorldUI is not
wrapped in chunks 1-8.** Chunk 3 D43 establishes WorldUI as a
bare empty Panel because no chunk-1-through-7 schema authors
world-anchored content. Chunk 8 doesn't add world-anchored
content either — title, overworld, and battle authoring stays
screen-only. Lifecycle-on-the-mode-root therefore only
animates the ScreenUI side. If a future chunk authors
world-anchored content with mode-root lifecycle requirements,
that chunk adds a distinct WorldUI wrapper ID at the same time
(e.g., `JoinNodeID(rootSchemaID, rootSchemaID, "lifecycle/world")`)
and decides whether enter/exit animation declarations apply
to one or both surfaces. Until then, WorldUI's mode-root
output is unwrapped — no animator target on the WorldUI side.

If future content needs richer mode-root layout for title or
overworld, those modes' bridge components are authored at that
time. Chunk 8 keeps them on the synthetic-root path because
the `mode_save_policy = "never"` and persistent-children
declarations live on the schema; no layout chrome is needed
beyond the synthetic anchored panel chunk 3 D43 produces.

This is the same wrapping step chunk 7 added for non-root flow
nodes (chunk 7 D96 / D97 / D98); chunk 8 just removes chunk 7
D98's "mode root NOT wrapped" exclusion.

Schema-level enter/exit animations declared on root-profile
schemas (allowed at parse time per chunk 7 D99 / D100 but
unplayed in chunk 7) now play. Chunk 7's deferred-list entry
"Mode-root lifecycle animations" closes here.

#### D117. Persistent passive + persistent interactive exit-on-mode-end

Reverses chunk 7 §10.3.4's "persistent passive nodes' exit
animations never play" and chunk 4 D53's "complete/cancel resets
to initial step for persistent interactive" — both yield to the
mode-end lifecycle:

- **Persistent passive nodes:** during `BeginModeExit`, walked
  and marked exiting. Their declared `exit_animations` play
  against their respective wrappers.
- **Persistent interactive nodes:** during `BeginModeExit`,
  marked exiting (does NOT call complete/cancel directives;
  mode-end is termination, not flow lifecycle — per Otto's M4
  lock from chunk 8 scoping). Their declared `exit_animations`
  play.

Chunk 7 D102's `MarkExiting` panics on `node.Persistent`. Chunk
8 amends with a second exported entry point that permits
persistent nodes for the mode-end path:

```go
// Chunk 7 D102 amended:
// MarkExiting (existing, panics on persistent) is unchanged.
// Chunk 8 adds MarkExitingForModeEnd as a separate exported
// entry point. Both share an internal helper.

// MarkExiting is the chunk-7 directive-driven path. Panics on
// persistent nodes (chunk 7 D102's contract preserved).
func (tm *TreeManager) MarkExiting(node *FlowNode) error {
    return tm.markExitingInternal(node, false)
}

// MarkExitingForModeEnd is the chunk-8 mode-end path. Permits
// persistent nodes; callable from game/interaction/runtime via
// the exported name.
func (tm *TreeManager) MarkExitingForModeEnd(node *FlowNode) error {
    return tm.markExitingInternal(node, true)
}

// markExitingInternal is the unexported shared implementation.
func (tm *TreeManager) markExitingInternal(node *FlowNode, modeEnd bool) error {
    if node.Exiting {
        return nil
    }
    if node.Persistent && !modeEnd {
        panic("MarkExiting: persistent node outside mode-end path")
    }
    // ... rest of chunk 7 D102 implementation:
    // 3. node.FrozenViewState = node.ViewState
    // 4. node.Exiting = true
    // 5. populate ExitKeys from schema
    // 6. routing.HandleExitStart(tm, node) for focus handoff
    return nil
}
```

`Runtime.BeginModeExit` calls `tm.MarkExitingForModeEnd(node)`
(exported, capitalized) for each persistent node. The non-mode-
end `MarkExiting` path (chunk 7 D108 directive-driven
complete/cancel) continues to panic on persistent nodes.

`MarkModeRootExiting` (D115) is a separate exported method
because the mode root has additional state mutations (it owns
the wrapper at its self-rooted ID per D116) and bypasses focus
handoff (mode root never held focus in the same way other nodes
do).

#### D118. Mode-wide exit-phase exclusions

During `TransitionPhase == TransitionExiting`, the entire mode is
in exit lifecycle. Chunk 8 extends chunk 7 D110's exiting-node
exclusions mode-wide:

- **Input:** `Runtime.HandleFrame` checks `r.modeExiting` flag at
  the top of input routing; if set, `RouteInput` is skipped
  entirely (player input is dropped during exit phase).
  Animations continue to advance via `animator.Advance`.
- **Policy aggregation:** chunk 6 D84/D85/D86 aggregators
  (`ResolvePausePolicy`, `CanSave`, `InputSuppressed`,
  `OpenSuppressed`) all return their base/default values during
  the exit phase — same as the chunk 7 D110 per-node exclusion,
  but applied uniformly. The mode is "in transition," not in a
  meaningful policy-aggregation state.
- **Focus:** the mode root and all child wrappers have their
  rendered subtree's `Focus.Focusable = false` post-processed
  (chunk 7 D110's Render-side processing extended to the entire
  rendered tree when `r.modeExiting` is set).
- **Open suppression:** OpenFlow directives received during the
  exit phase are dropped silently (no error, no opening — the
  mode is going away).

`Runtime.modeExiting` is the local flag set by `BeginModeExit`,
cleared on `Close()` (when the runtime instance is dropped).

#### D119. Quit lifecycle through exit phase

`HostRequestQuit` (D111) goes through the exit phase, NOT direct
shutdown. The host pipeline's quit signal continues to flow
through `RuntimeControlHooks.QuitRequested() bool` (a query
method, unchanged). ModeController gains internal state for the
in-game quit path:

```go
type ModeController struct {
    // ... existing fields ...
    pendingQuit      bool   // chunk 8 — set by RequestQuit
    quitAcknowledged bool   // chunk 8 — set after exit-complete
}

func (mc *ModeController) QuitRequested() bool {
    // Acknowledged in-game quit OR existing OS-level signal.
    if mc.quitAcknowledged {
        return true
    }
    if mc.quitFn != nil && mc.quitFn() {
        return true
    }
    return false
}

func (mc *ModeController) RequestQuit() error {
    mc.pendingQuit = true
    return nil
}
```

Routing flow (across multiple frames — the engine pipeline
ordering is `ResolveFramePolicy` runs BEFORE `HandleFrame`, and
host effects from HandleFrame execute AFTER HandleFrame
returns):

1. **Frame N — UI produces quit.** HandleFrame's
   `dispatchOutcomes` consumes a flow's `QuitRequested = true`
   directive and emits `HostRequest{Kind: HostRequestQuit}`.
   Engine pipeline executes the host request after HandleFrame
   returns: executor calls `transition.RequestQuit()` (D112) →
   routes to `mc.RequestQuit()` via the `transitionAdapter` →
   sets `mc.pendingQuit = true`.
2. **Frame N+1 — exit phase begins.** Pipeline's
   `ResolveFramePolicy` runs first; it calls
   `applyPendingTransition` which sees `pendingQuit`, calls
   `runtime.BeginModeExit()` (state-only per D115), and sets
   `transitionPhase = TransitionExiting`. Then HandleFrame
   runs; Phase 0 (D115) calls `startModeExitAnimations` because
   `r.modeExiting && !r.modeExitAnimsStarted`. Animations begin.
3. **Frames N+2..N+K — exit animations play.** Each frame's
   `applyPendingTransition` checks `IsModeExitComplete()`;
   while false, the controller stays in Exiting and HandleFrame
   keeps running with mode-wide exit-phase exclusions (D118).
4. **Frame N+K — exit complete.**
   `IsModeExitComplete()` returns true.
   `executePendingSwap()` runs with the pendingQuit branch:
   sets `mc.quitAcknowledged = true`, clears `pendingQuit`,
   clears `pending` (D114's amended swap). ModeController's
   `QuitRequested()` query now returns true.
5. **Frame N+K (continued).** Host pipeline polls
   `QuitRequested()` (existing pattern, no changes to that
   surface); observes the flip and exits the game loop.

Same one-frame-of-latency holds for transitions (D113): a
`HostRequestModeTransition` produced in frame N is consumed by
the executor after frame N's HandleFrame, sets `mc.pending` on
ModeController; frame N+1's `ResolveFramePolicy` begins the
exit phase. Players don't perceive the latency because frames
are 16ms; the exit animation plays starting with frame N+1.

The existing `quitFn func() bool` injected into ModeController
(verified at [game/mode_controller.go:62](game/mode_controller.go#L62))
is **a query**, not a shutdown-trigger function — earlier scoping
confused the two. It returns whether `gameModule.QuitRequested()`
sees an OS-level signal. Chunk 8 preserves that — `quitFn`
continues to be called from `mc.QuitRequested()` so OS-level
close-events still flip the host's poll. The new in-game path
adds `quitAcknowledged` as a second source the same query
aggregates.

If a player presses the OS's window-close X (which bypasses
HostRequestQuit and goes directly to OS-level signaling that
`quitFn` returns true for), the existing behavior persists —
exit anims do NOT play, the game closes immediately because
`quitAcknowledged` is false but `quitFn()` returns true.
Chunk 8 doesn't try to intercept OS-level close events; only
the in-game quit flow runs through the exit phase.

**OS quit during in-flight exit phase.** If `quitFn()` returns
true at any point during a `TransitionExiting` phase (in-game
quit OR transition exit anims still playing), `mc.QuitRequested()`
returns true on the host's next poll because the aggregator OR's
`quitFn() || quitAcknowledged`. The host loop exits immediately,
abandoning the in-flight exit animation. This is intentional —
OS-level signals always trump in-game state, including in-flight
transition lifecycle.

#### D120. `processCues` implementation — replaces chunk 6 D83 stub

Chunk 6 D83's `processCues` was a no-op stub awaiting the cue
infrastructure migration. Chunk 8 implements:

```go
// game/interaction/runtime/cues.go

import "afterimage/engine/sim/cues"

// Preserves the legacy game/flow.CueKindOpenFlow string value
// so existing cue producers (input handlers, ECS systems, Lua
// scripts) continue to emit the same kind without migration.
// The constant is redefined here so this package doesn't import
// the soon-to-be-deleted game/flow package.
const cueKindOpenFlow = "cue.ui.menu.open_flow"

func processCues(r *Runtime, ctx pipeline.FrameContext) error {
    var errs []error
    ctx.Cues.ForEach(func(cue cues.Cue) {
        if cue.Kind != cueKindOpenFlow {
            return
        }
        req, err := decodeOpenFlowPayload(cue.Payload)
        if err != nil {
            errs = append(errs, fmt.Errorf("cue at tick %d seq %d: %w",
                cue.CueBatchSimTick, cue.Seq, err))
            return
        }
        if err := r.attachOpenFlow(req, r.lastScreenUI); err != nil {
            errs = append(errs, fmt.Errorf("attach for cue at tick %d seq %d: %w",
                cue.CueBatchSimTick, cue.Seq, err))
        }
    })
    return errors.Join(errs...)
}
```

**Cue kind value preserved.** The constant value
`"cue.ui.menu.open_flow"` matches the existing
`game/flow.CueKindOpenFlow` constant
([game/flow/builder.go:9](game/flow/builder.go#L9)). Chunk 8
does NOT migrate producers to a new string — the producer-side
contract is preserved. The constant is redeclared in
`game/interaction/runtime/cues.go` so the runtime package
doesn't import the soon-to-be-deleted `game/flow`.

`decodeOpenFlowPayload` parses the cue's `Payload` string into
an `interpreter.OpenFlowRequest`. The legacy payload format is
`"flow=<schema_id>;key=<value>"` (verified in
[game/flow/req_decode.go](game/flow/req_decode.go)). Chunk 8
locks the field mapping precisely:

- `flow=<value>` → `OpenFlowRequest.SchemaID = <value>`. Required
  field; an empty or missing `flow=` entry produces a parse
  error (rejected with `opCueOpenFlowMissingSchema` op).
- `key=<value>` → `OpenContext.Params["key"] = <value>`. Optional
  field; if absent, `OpenContext.Params` is nil. Schema functions
  read `ctx.params.key` per chunk 4 D54 to retrieve it.
- Unknown `<name>=<value>` segments are silently dropped (no
  error). Future payload extensions can add new keys without
  breaking forward compatibility.

**Malformed payload handling (locked):**

- **Segment without `=`** (e.g., `"flow=foo;malformed"`) →
  rejected with `opCueOpenFlowMalformedSegment`.
- **Empty segment from trailing `;`** (e.g., `"flow=foo;"`) →
  ignored (treated as no segment present after the trailing
  separator). This makes producers tolerant of extra
  separators.
- **Empty key name** (e.g., `"=baz"` or `";=v;flow=foo"`) →
  rejected with `opCueOpenFlowMalformedSegment`. A key name
  must be at least one character.
- **Duplicate `flow=`** (e.g., `"flow=foo;flow=bar"`) → rejected
  with `opCueOpenFlowDuplicateField`. Multiple authoritative
  schema IDs in one cue is a producer bug; reject loudly rather
  than silently last-wins.
- **Duplicate `key=`** (e.g., `"flow=foo;key=a;key=b"`) →
  rejected with `opCueOpenFlowDuplicateField`. Same rationale.
- **Empty value** (e.g., `"flow="` or `"flow=foo;key="`) →
  treated as the field having empty string. For `flow=`, this
  triggers `opCueOpenFlowMissingSchema` (empty SchemaID is
  invalid). For `key=`, an empty value is permitted (becomes
  `OpenContext.Params["key"] = ""`).

If the producer-side payload format ever migrates (e.g., to JSON),
the parser updates. Chunk 8 doesn't introduce that migration —
preserves the legacy "k=v;k=v" format verbatim.

**Behavior change from `game/ui_cues.go`.** The legacy filter
loop ([game/ui_cues.go](game/ui_cues.go)) returned an error when
multiple `open_flow` cues appeared in a single batch (the legacy
contract assumed at most one open_flow per frame). Chunk 8's
`processCues` deliberately changes this: multiple cues in one
batch are processed in declaration order (the cue's `Seq` field
provides deterministic order across the batch). Each
`attachOpenFlow` failure aggregates via `errors.Join`; subsequent
cues are not skipped on a prior failure.

Rationale: the new runtime supports multiple transient flows
attached simultaneously. Cue-driven attaches don't conflict with
each other unless two cues request the same schema (in which
case the second's `attachOpenFlow` call returns the standard
chunk 6 D79 attach-conflict error, surfaced via errors.Join).
This is a deliberate broadening of capability.

**Cue timing — runs BEFORE Render, using prior-frame `r.lastScreenUI`.**
The cue-driven attach calls `r.attachOpenFlow(req, screenUI)`
which needs A screen tree for chunk-5 HandleAttach's
deferred-focus path. The previous frame's tree
(`r.lastScreenUI`, stored on Runtime per chunk 6 D79) is the
available reference; the just-attached node's subtree won't
appear until the next frame's render anyway, so deferred-focus
naturally handles the timing.

`HandleFrame` ordering with chunk 8 additions. The chunk-6 D77
nil-animator guard runs FIRST; chunk 8's Phase 0 runs after the
guard so `ctx.Animator` is verified non-nil before
`startModeExitAnimations` dereferences it:

```
// Existing chunk 6 D77 nil-animator guard runs FIRST. If
// ctx.Animator is nil, errorSink receives opRuntimeNilAnimator
// and HandleFrame returns the fallback FrameOutput. Chunk 8's
// Phase 0 below is unreachable on nil-animator frames.
if ctx.Animator == nil {
    r.errorSink.Report(failures.Rejectf(opRuntimeNilAnimator, "animator", "animator is nil"))
    return r.fallbackOutput()
}

// Phase 0 (chunk 8 D115): start mode-exit animations if pending.
if r.modeExiting && !r.modeExitAnimsStarted {
    _ = r.startModeExitAnimations(ctx.Animator)  // errors via errorSink
}

// Existing chunk 6 phases 1-5: dispatch directives (which may
// produce HostRequestModeTransition / HostRequestQuit per D113,
// HostRequestOpenFlow per D80, etc.).
// ...

// Phase 5.5 (chunk 8 D120): cue-driven open_flow.
if err := processCues(r, ctx); err != nil {
    r.errorSink.Report(err)
    // continue — cue errors don't tear down the frame
}

// Phase 6 (chunk 6 D79 / chunk 7 D107): Render.
rendered, err := r.tm.Render(ctx.Animator, ctx.Style, stepViews)
// ...

// Phase 6.5: retryDeferredFocus
// Phase 7: SKIP (engine drives Apply)
// Phase 8 (chunk 7 D107): r.tm.ReapExiting(ctx.Animator)
// Phase 9: SKIP (engine drives Sweep)
```

**Ordering invariant locked:** chunk-6 nil-animator guard
precedes any chunk-8 Phase 0 logic. A literal implementation
that flips the order would crash on a nil-animator frame; the
chunk-6 fallback path (FrameOutput skipped, error sink
notified) must remain reachable for the legacy/test paths that
construct FrameContext without an animator. The chunk-6 D77
contract stays exactly as written.

**Layer guard amendment (D90 retrofit).** Chunk 6 D90's runtime
layer guard forbids `engine/sim` as a prefix-match rule (the
existing guard mechanism uses `ForbiddenBase` which prefix-
matches). Prefix forbid wins; the guard has no
allowed-exception mechanism. Chunk 8 cannot simply "add an
exception" — it must replace the broad forbid with explicit
forbids of the heavier subpackages.

Chunk 8 amends chunk 6 D90's `ForbiddenBase` list for
`game/interaction/runtime`:

- **Remove** `modulePath + "/engine/sim"` (the broad prefix forbid).
- **Add explicitly** the root `engine/sim` package and each heavy
  subpackage by exact name. Per current repo state (verified
  via `Glob` at chunk-8 spec time):
  - `modulePath + "/engine/sim"` — via `ExactImport` rule (or
    equivalent), forbidding the root package itself. The
    `engine/sim` root has files like `clock.go`, `config.go`,
    `vocabulary.go`, `types.go` that the runtime should NOT
    reach into.
  - `modulePath + "/engine/sim/rng"` — forbidden.
  - `modulePath + "/engine/sim/scheduler"` — forbidden.
  - `engine/sim/cues` is **omitted** from the forbidden list,
    making it implicitly allowed.

The implementer re-enumerates `engine/sim/` at chunk-8
implementation time to catch any subpackages added between spec
authoring and prompt execution. The locked policy: every
`engine/sim/*` subpackage is forbidden EXCEPT `engine/sim/cues`,
plus the `engine/sim` root package itself is forbidden via
`ExactImport`.

Other chunks (chunk 1's schema, chunk 2's tree, etc.) keep their
broad `engine/sim` prefix forbid because their guard rules don't
need to import `engine/sim/cues` — only the runtime does. Their
rules are unchanged.

The legacy `game/ui_cues.go` filter loop is deleted alongside
`game/flow/` (D129).

#### D121. `session.ModeSession` interface gains `CanSave() bool`

Per Q1's locked clarification, `ModeSession` interface adds:

```go
// engine/session/session.go (modified):

type ModeSession interface {
    Clone() ModeSession
    TypeName() string

    // CanSave reports whether this mode permits save operations
    // at all. Returns true for modes whose root schema declares
    // mode_save_policy = "allowed"; false for "never". This is
    // a static per-mode answer derived once at session
    // construction; not aggregated across runtime state (that's
    // policy.CanSave(tm) per chunk 6 D85). Both layers must
    // return true for save to proceed (D124).
    CanSave() bool
}
```

Per-mode implementations derive the value at construction by
reading the mode's root schema. Chunk 8 locks the
`sessionFactory` plumbing so the implementer doesn't have to
invent it:

**`sessionFactory` closure captures `schema.Registry`.** The
existing `ModeSessionFactory` type
([game/mode_controller.go:41](game/mode_controller.go#L41)) is
`func(m mode.GameMode) session.ModeSession`. Chunk 8 changes
how it's constructed — the closure built in `game/run.go`
captures the schema registry via lexical closure:

```go
// In game/run.go (chunk 8 modified):
schemaReg, err := schema.Load(catalog, scripts)  // chunk 1 registry
if err != nil { return err }

sessionFactory := func(m mode.GameMode) session.ModeSession {
    rootSchemaID := schemaIDForMode(m)            // "title_root", etc.
    rootSchema, ok := schemaReg.Lookup(rootSchemaID)
    if !ok {
        // Schema missing — load failed loudly upstream; here we
        // panic because the constructor invariant says all modes
        // have schemas. Bucket A.
        panic(fmt.Sprintf("sessionFactory: schema %q missing", rootSchemaID))
    }
    canSave := rootSchema.Root.ModeSavePolicy == schema.ModeSaveAllowed

    switch m {
    case mode.ModeTitle:
        return &TitleSession{canSave: canSave}
    case mode.ModeOverworld:
        return &OverworldSession{canSave: canSave}
    case mode.ModeBattle:
        return &BattleSession{canSave: canSave}
    default:
        panic(fmt.Sprintf("sessionFactory: unknown mode %d", m))
    }
}

mc, err := game.NewModeController(initial, ..., sessionFactory, ...)
```

`schemaIDForMode(m mode.GameMode) string` is a small helper in
`game/sessions.go` that maps `mode.ModeTitle → "title_root"`,
etc. — implementation-locked at chunk-8 time.

The `ModeController.sessionFactory` field signature stays the
same; only the closure construction changes. ModeController
itself doesn't need a schema registry reference; the factory's
closure carries it.

For chunk 1's content: title_root authors `mode_save_policy =
"never"` → TitleSession.CanSave() = false. Overworld + battle
author `"allowed"` → true.

#### D122. Per-mode `ModeSession` reshape — subtraction

`game/sessions.go` (existing, [game/sessions.go](game/sessions.go))
loses its legacy menu/flow fields:

**Before (existing):**

```go
type TitleSession struct { Menu host.MenuState }
type OverworldSession struct {
    Menu host.MenuState
    Flow flow.FlowState
}
type BattleSession struct {
    Menu host.MenuState
    Flow flow.FlowState
}

type SessionMenuAccess interface { MenuPtr() *host.MenuState }
type SessionFlowAccess interface { FlowPtr() *flow.FlowState }
```

**After (chunk 8):**

```go
type TitleSession struct { canSave bool }
type OverworldSession struct { canSave bool }
type BattleSession struct { canSave bool }

// TypeName() preserved (ModeSession interface still requires it):
func (s *TitleSession) TypeName() string     { return "title" }
func (s *OverworldSession) TypeName() string { return "overworld" }
func (s *BattleSession) TypeName() string    { return "battle" }

// CanSave() added per D121:
func (s *TitleSession) CanSave() bool     { return s.canSave }
func (s *OverworldSession) CanSave() bool { return s.canSave }
func (s *BattleSession) CanSave() bool    { return s.canSave }

// Clone implementations per D123 (below).
// SessionMenuAccess / SessionFlowAccess interfaces deleted.
```

The runtime's TreeManager + Interpreter own the mode's per-frame
state directly; ModeSession does NOT mirror it. This is the
fundamental simplification chunk 8 lands: legacy menu/flow state
lived in ModeSession because adapters projected it; the new
runtime owns its state in tree + interpreter, and ModeSession
exists only as a mode identity + CanSave carrier.

Per-system rollback (`BeginSystemRun` / `RollbackSystem`)
continues to operate on ModeSession via `Clone()` (D123), but
since post-chunk-8 ModeSession holds only `canSave bool` (an
immutable-after-construction value), Clone is essentially
free and rollback is a no-op for ModeSession content.

The runtime is NOT inside ECS-system execution windows
(HandleFrame runs in the post-simulation pipeline phase per the
boundary architecture), so ECS-system failure-driven rollback
does not need to undo runtime state mutations. This is
consistent with the chunk-6 boundary placement of HandleFrame.

#### D123. `Clone()` implementations — trivial after subtraction

Each per-mode session's `Clone()` implementation simplifies:

```go
func (s *TitleSession) Clone() session.ModeSession {
    if s == nil {
        return nil
    }
    return &TitleSession{canSave: s.canSave}
}
// OverworldSession, BattleSession: same shape.
```

The legacy `cloneMenuState` / `cloneFlowState` helpers in
`game/sessions.go` are deleted alongside the field types.

#### D124. F5 save-callback gate retrofit — `game/run.go`

The existing F5 quicksave callback in `game/run.go` is gated
today on legacy state:

```go
// Pre-chunk-8 (verified in docs/systems/save_load.md §d):
if gameModule.UI().Flow.Active || gameModule.UI().Menu.Len() > 0 {
    return  // skip save
}
```

Chunk 8 retrofits the gate to use the new two-layer save policy:

```go
// Post-chunk-8:
sess := modeStore.Read()
if sess == nil {
    return  // no active session, no save
}
if !sess.CanSave() {
    return  // mode-level "never save"
}
runtime := mc.runtimeForMode(mc.current)
if runtime == nil || !policy.CanSave(runtime.TreeManager()) {
    return  // tree-aggregated policy blocks
}
// Both gates pass — proceed with save.
saveGame(...)
```

`policy.CanSave(tm)` is chunk 6 D85's existing aggregator
(reads root schema's `mode_save_policy` AND walks the tree to
check transient flow nodes' policies). `sess.CanSave()` is the
mode-level static check from D121.

The save **file format is unchanged** — still
`SaveFile{Version, Metadata, ECSWorld}` per
`docs/systems/save_load.md` §b. The runtime's per-frame state
is NOT serialized into the save file. The two-layer gate ensures
saves only happen in a clean state (no transient flows open, no
mid-step persistent interactive nodes), so the load path's
"fresh runtime on overworld boot" continues to work without
runtime payload reconstruction.

`runtime.TreeManager()` is a new accessor on Runtime that
returns the underlying `*tree.TreeManager` — needed because
`policy.CanSave` takes a TreeManager directly. This is a small
runtime surface addition; not a layer-guard concern (game/run.go
already imports `game/interaction/runtime`).

Load path is unchanged. `--load` still boots overworld with the
loaded ECS world; the runtime is constructed fresh by
`enterMode(ModeOverworld)`. Chunk 7 D101's `NeedsEnterAnimations`
flag is set on persistent nodes during NewTreeManager /
NewRuntime, so load's first render plays the overworld's
mode-root enter animation naturally — no special-case load path
needed.

#### D125. `title_root.lua` content authoring

Chunk 1 authored `title_root.lua` as a stub schema (per chunk 1
§4.2.1's authored content list). Chunk 8 fleshes it into real
content:

```lua
-- game/assets/scripts/game/interaction/schemas/title_root.lua
return {
    id = "title_root",
    profile = "root",
    persistent_children = { "title_menu" },
    navigation = {},
    mode_save_policy = "never",
    enter_animations = {
        { key = "title_fade_in", property = "opacity",
          from = 0.0, to = 1.0, duration = 0.3,
          easing = "ease_out", mode = "one_shot" },
    },
    exit_animations = {
        { key = "title_fade_out", property = "opacity",
          from = 1.0, to = 0.0, duration = 0.3,
          easing = "ease_in", mode = "one_shot" },
    },
}
```

`title_menu` is the persistent interactive flow node that owns
the title menu UI (Start Game / Quit). Authored alongside (per
M5 lock — title menu is a real interactive flow):

```lua
-- game/assets/scripts/game/interaction/schemas/title_menu.lua
return {
    id = "title_menu",
    profile = "interactive",
    initial_step = "main",
    focus_entry = "first_focusable",
    focusable = true,
    pause_request = "never",
    steps = {
        main = {
            type = "choice",
            options = {
                { key = "start", label = "Start Game" },
                { key = "quit",  label = "Quit" },
            },
            on_select = function(ctx, accum, selection)
                if selection.key == "start" then
                    return { transition = "overworld" }
                elseif selection.key == "quit" then
                    return { quit = true }
                end
                return { next = "main" }
            end,
        },
    },
}
```

`focus_entry = "first_focusable"` matches chunk 5 D69's
resolution rules (lifts focus to the first focusable child of
the rendered subtree). `"named:main"` would not work because
`main` is a step ID in the schema, not a UINode local ID — the
choice step's emitted UINodes are named `options` and
`opt_<key>` per the existing chunk-4 `choice.lua` step component.

`on_select(ctx, accum, selection)` matches chunk 4 D56's signature
(verified in
[spec_phase_6_flow_tree_runtime.md:3176](spec_phase_6_flow_tree_runtime.md#L3176)).
The selection is the third argument; `selection.key` is the
selected option's key.

`title_root` declares `title_menu` as a persistent child (per
chunk 1 D14's persistent_children pattern). The runtime opens
title_menu at mode-enter (chunk 6 D79 NewRuntime).

Selecting "Start Game" emits a `transition` directive (chunk 4
D57) carrying `mode.GameMode = ModeOverworld`. The runtime's
`dispatchOutcomes` (D113) emits a `HostRequestModeTransition`
which the executor routes to `mc.RequestTransition(ModeOverworld)`.

Selecting "Quit" emits a `quit = true` directive. The runtime's
dispatchOutcomes emits a `HostRequestQuit` which routes to
`mc.RequestQuit()`. Per D119, exit anims play across frames N+1
through N+K; on frame N+K, `mc.quitAcknowledged` flips to true,
and `mc.QuitRequested()` returns true on the host's next poll.

Authoring contract: `title_menu.lua` is **not** authored in
chunk 1 (chunk 1 §4.2.1 lists only the five schemas
`battle_root`, `title_root`, `overworld_root`, `party_status`,
`pause_menu`). Chunk 2 D35 authors `title_menu.lua` as a
passive-profile stub so `title_root.persistent_children =
["title_menu"]` resolves; chunk 4 D61 rewrites it as
interactive. The D125 content above is the final chunk-8
flesh-out — `on_select` body and the Start Game / Quit option
list are the chunk-8 additions.

#### D126. `overworld_root.lua` content authoring

```lua
-- game/assets/scripts/game/interaction/schemas/overworld_root.lua
return {
    id = "overworld_root",
    profile = "root",
    persistent_children = {},  -- pause_menu attaches transiently
    navigation = {},
    mode_save_policy = "allowed",
    enter_animations = {
        { key = "overworld_fade_in", property = "opacity",
          from = 0.0, to = 1.0, duration = 0.3,
          easing = "ease_out", mode = "one_shot" },
    },
    exit_animations = {
        { key = "overworld_fade_out", property = "opacity",
          from = 1.0, to = 0.0, duration = 0.3,
          easing = "ease_in", mode = "one_shot" },
    },
}
```

No persistent children — `pause_menu` (chunk 1 authored;
schema's `pause_request = "always"` per design spec §11.1)
attaches transiently when the player presses Escape.

The Escape-to-pause-menu wiring is a CUE-driven open_flow —
input handler emits a cue with `Kind = "cue.ui.menu.open_flow"` and a payload
specifying `pause_menu` as the schema_id. This is the integration
proof for D120's `processCues` implementation.

Producer side: the Escape key handler emits the cue. Chunk 8
verifies the producer site (likely in `engine/input/input.go` or
a Lua input handler — implementer locates and confirms it
emits with `Kind = cueKindOpenFlow`).

**Battle schema gets matching enter + exit animations.** Chunk 7
D109 authored the `battle_root` **component** at
`game/assets/scripts/game/ui/components/battle_root.lua` (the
bridge component returning the layout panel). Chunk 8 modifies
the **schema** at
`game/assets/scripts/game/interaction/schemas/battle_root.lua`
(the chunk-1 stub schema, distinct file) to add lifecycle
animation declarations:

```lua
-- game/assets/scripts/game/interaction/schemas/battle_root.lua
-- Chunk 8 amends the chunk-1 stub schema with these fields:
enter_animations = {
    { key = "battle_fade_in", property = "opacity",
      from = 0.0, to = 1.0, duration = 0.3,
      easing = "ease_out", mode = "one_shot" },
},
exit_animations = {
    { key = "battle_fade_out", property = "opacity",
      from = 1.0, to = 0.0, duration = 0.3,
      easing = "ease_in", mode = "one_shot" },
},
```

The bridge component file is unchanged — animations are
schema-level per chunk 7 D99 / D100 (the parser surface), not
component-level.

#### D127. Cutover ON for all modes + delete the per-mode flag

Chunk 6 D78 introduced the per-mode `UseTreeRuntime bool` flag.
Chunk 7's content (battle_root real component) and chunk 8's
title + overworld content (D125, D126) make the flag's
"all modes ON" state authored.

Chunk 8 flips:
- title: ON
- overworld: ON
- battle: ON (already from chunk 6)

Then **deletes the flag entirely** (per Q4 lock). The mode-config
struct loses the field. ModeController's `enterMode` /
`exitMode` no longer branch on the flag — they unconditionally
construct/destroy the new runtime per chunk 6 D79's pattern.

Test harnesses that constructed mode-state with the flag set
update accordingly (no flag to set; runtime always present).

#### D128. Delete `game/menu/` package + adapters

```
DELETED:
  game/menu/                           (12 files — see chunk 8 §11.2.3)
  game/menu_runtime_adapter.go
```

All references to `menu.Runtime`, `menu.Definition`,
`menu.Library`, `menu.RuntimeState`, etc. are removed.

`game/sessions.go` loses `host.MenuState` field references
(D122).

`game/session_helpers.go` loses `menuFromStore` helper (only
caller was the legacy adapter).

`engine/ui/host.MenuState` and `engine/ui/host.NewMenuState` are
**preserved** — `host.MenuState` is an engine-side type that
other code may use (UI host runtime side); chunk 8 only deletes
the game-side menu runtime, not the engine-side menu type. The
implementer verifies no other consumer of `host.MenuState`
exists; if it's only the deleted adapter, the engine-side type
also deletes.

#### D129. Delete `game/flow/` package + adapters

```
DELETED:
  game/flow/                           (19 files — see chunk 8 §11.2.3)
  game/flow_runtime_adapter.go
  game/ui_cues.go                      (legacy cue filter loop)
  game/ui_cues_test.go
```

All references to `flow.Runtime`, `flow.FlowState`, `flow.Step`,
`flow.Library`, `flow.CueKindOpenFlow`, etc. are removed.

`game/sessions.go` loses `flow.FlowState` field references
(D122).

`game/session_helpers.go` loses `flowFromStore` helper.

`flow.CueKindOpenFlow` constant is replaced by the chunk-8
`cueKindOpenFlow = "cue.ui.menu.open_flow"` constant in
`game/interaction/runtime/cues.go` (D120). String value
preserved so existing producers continue to emit the same kind.

#### D130. Layer guard cleanup

`internal/importguard/import_guard_test.go` rules referencing
`game/menu` or `game/flow` are deleted or simplified:

- Layer guard rules WHERE these packages were the importer:
  the entire rule entry is deleted.
- Layer guard rules WHERE these packages were forbidden imports
  for some other package: the forbidden-list entry pointing to
  `game/menu` or `game/flow` is deleted (since the package no
  longer exists, the entry is moot but might fail import-guard
  list-stability checks if not pruned).

Affected layer rules in chunks 1-7 (forbidden lists pruned):
- `game/interaction/schema` (chunk 1)
- `game/interaction/tree` (chunk 2)
- `game/interaction/interpreter` (chunk 4)
- `game/interaction/routing` (chunk 5)
- `game/interaction/runtime` (chunk 6)
- `game/interaction/policy` (chunk 6)
- `game/ui/animlua` (chunk 7)

Each of these had `game/menu` and `game/flow` in their forbidden
lists. After deletion, those entries are pruned to keep the
guard's "every entry references an extant package" invariant
clean.

`docs/architecture/layers.md` is updated to remove `game/menu`
and `game/flow` from its package inventory.

#### D131. Phase 6 closure verification

The chunk 8 acceptance gate is the Phase 6 closure proof: a
single integration test that exercises the full mode-traversal
sequence end-to-end, asserting:

1. **Boot to title.** Game starts in title mode. Title mode-root
   enter animation plays (visible via
   `animator.IsAnimating(title_wrapper_id)` true on first frame
   post-boot).
2. **Title menu navigation.** Player input selects "Start Game"
   on title_menu. Directive returns `transition = "overworld"`.
3. **Title→overworld transition.** ModeController begins exit
   phase. Title's mode-root exit animation plays. `IsModeExitComplete()`
   returns false during exit, true after duration. Mode swap
   fires; overworld's mode-root enter animation plays on the
   first overworld frame.
4. **Overworld pause-menu attach.** Player input emits
   open_flow cue for pause_menu. `processCues` (D120) attaches
   pause_menu transiently. pause_menu's enter animation plays
   (chunk 7 lifecycle).
5. **Pause-menu cancel.** Player input cancels pause_menu.
   Chunk 7 D108 routes to `tm.MarkExiting`; pause_menu enters
   exiting state. Exit animation plays; ReapExiting detaches.
6. **Overworld→battle transition.** (Triggered by a test-harness
   directive — overworld content needn't have a real
   "enter battle" UI for chunk 8.) Same exit/enter sequence as
   step 3.
7. **Battle commands work.** Chunk 6 D91's battle proof
   (party_status command_panel → `battle_action` command) still
   passes inside the new mode.
8. **F5 quicksave gate.** Pressing F5 in title mode: gate
   blocks because `TitleSession.CanSave()` returns false
   (title_root authors `mode_save_policy = "never"`). Pressing
   F5 in overworld with no transient flow open: gate proceeds
   to save. Pressing F5 in overworld while a transient flow
   that authors `save_suppress = "while_visible"` is open:
   gate blocks via `policy.CanSave(tm)` per chunk 6 D85
   (a test-harness flow with `save_suppress = "while_visible"`
   covers this case; pause_menu by default authors only
   `pause_request`, not `save_suppress`, so pause_menu does
   NOT block save unless explicitly authored to).
9. **Quit through exit phase.** Quit selected from title menu:
   exit anims play across frames N+1..N+K; on frame N+K
   `mc.quitAcknowledged` flips to true, and the host's next
   poll of `mc.QuitRequested()` returns true (which exits the
   game loop).

This integration test lives in `game/integration_phase6_test.go`
(or equivalent — implementer locates).

After chunk 8, `go run ./tools/devctl testall` passes with the
legacy menu/flow code completely absent from the build.

### 11.2 Chunk 8 acceptance

#### 11.2.1 New files

```
game/interaction/runtime/
  cues.go                — processCues impl + cueKindOpenFlow constant (D120)
  cues_test.go           — cue-driven open_flow integration

game/integration_phase6_test.go  — Phase 6 closure proof (D131)
```

Authored content updates (modifications, listed here for
visibility):

```
game/assets/scripts/game/interaction/schemas/
  title_root.lua         — fleshed out (D125)
  title_menu.lua         — fleshed out (D125)
  overworld_root.lua     — fleshed out (D126)
  battle_root.lua        — enter/exit anims added (D126)
```

#### 11.2.2 Modified files

```
engine/pipeline/host_effect_types.go
  — HostRequestKind gains HostRequestModeTransition + HostRequestQuit (D111)
  — HostRequest gains ModeTransition field (D112)

engine/runtime/host_effect_executor.go
  — TransitionPolicy interface gains RequestQuit method (D112)
  — HostEffectExecutor gains transition field + executor cases for
    HostRequestModeTransition + HostRequestQuit (D112)

(Note: pipeline.RuntimeControlHooks interface is unchanged —
quit requests flow through engine/runtime.TransitionPolicy, not
through RuntimeControlHooks. The existing QuitRequested() query
on RuntimeControlHooks remains the host-poll mechanism.)

engine/session/session.go
  — ModeSession interface gains CanSave() bool method (D121)

game/mode_controller.go
  — TransitionPhase state model (D114)
  — pendingQuit field
  — RequestQuit method
  — applyPendingTransition rewritten as async (D114)
  — runtimeForMode helper

game/interaction/runtime/runtime.go
  — BeginModeExit + IsModeExitComplete public methods (D115)
  — modeExiting flag + mode-wide exit-phase exclusions (D118)
  — TreeManager() accessor for save-gate (D124)
  — HandleFrame Phase 5.5 wires processCues (D120)
  — dispatchOutcomes routes Transition/Quit to host (D113)

game/interaction/tree/tree_manager.go
  — MarkExitingForModeEnd exported path that permits persistent nodes (D117)
  — MarkModeRootExiting method for mode-root state mutation (D115)

game/sessions.go
  — TitleSession/OverworldSession/BattleSession reshape (D122)
  — Clone() simplifies (D123)
  — Constructors derive canSave from schema (D121)

game/session_helpers.go
  — menuFromStore + flowFromStore helpers deleted (D128, D129)

game/run.go
  — F5 save-callback gate retrofit (D124)

game/frame_handler.go (or wherever)
  — references to menu/flow runtime adapters removed

internal/importguard/import_guard_test.go
  — game/menu and game/flow rules deleted (D130)
  — forbidden-list entries pruned across chunks 1-7 layer rules (D130)

docs/architecture/layers.md
  — game/menu and game/flow removed from inventory (D130)

cmd/game/main.go
  — per-mode UseTreeRuntime flag references deleted (D127)

game/mode/mode.go (or wherever cutover flag lived)
  — UseTreeRuntime field deleted (D127)

game/assets/catalog.json
  — regenerated by tools/cataloggen/ for any new authored Lua content
```

#### 11.2.3 Deleted files

```
game/menu/
  actions.go
  actions_test.go
  definition.go
  library.go
  library_drain_test.go
  parse.go
  renderer.go
  renderer_test.go
  runtime.go
  runtime_test.go
  validate.go
  validate_test.go

game/flow/
  builder.go
  builder_test.go
  instance.go
  library.go
  library_drain_test.go
  library_ecs_test.go
  library_test.go
  lua_json.go
  lua_json_test.go
  parse_validate.go
  parse_validate_test.go
  registry.go
  renderer.go
  renderer_test.go
  req_decode.go
  req_decode_test.go
  runtime.go
  runtime_test.go
  types.go

game/menu_runtime_adapter.go
game/flow_runtime_adapter.go
game/ui_cues.go
game/ui_cues_test.go

game/interaction/runtime/errors.go (op constants):
  opRuntimeTransitionDropped  — deleted (D113)
  opRuntimeQuitDropped        — deleted (D113)

game/sessions.go (helper deletions, file persists):
  cloneMenuState helper       — deleted
  cloneFlowState helper       — deleted
  SessionMenuAccess interface — deleted
  SessionFlowAccess interface — deleted
```

Total Go-file deletions: 35 files (12 in `game/menu/` + 19 in
`game/flow/` + 4 game-layer files: `menu_runtime_adapter.go`,
`flow_runtime_adapter.go`, `ui_cues.go`, `ui_cues_test.go`).

#### 11.2.4 Test matrix

`engine/runtime/host_effect_executor_test.go` (additions):
- `HostRequestModeTransition` routes to `transition.RequestTransition(int)`.
- `HostRequestQuit` routes to `transition.RequestQuit()`.
- Existing kinds (`SetWindowMode`, `OpenFlow`, `WarnNotImplemented`)
  still work unchanged.

`game/mode_controller_test.go` (additions):
- `RequestTransition` → next frame begins TransitionExiting.
- TransitionExiting → IsModeExitComplete false → stay in current mode.
- TransitionExiting → IsModeExitComplete true → swap fires, current = next.
- `RequestQuit` → next frame begins TransitionExiting.
- TransitionExiting (quit) → IsModeExitComplete true →
  executePendingSwap sets mc.quitAcknowledged = true; subsequent
  mc.QuitRequested() returns true.
- Both pending and pendingQuit set: quit wins.
- `applyPendingTransition` no-op when both pending+pendingQuit are zero.

`game/interaction/runtime/runtime_test.go` (additions):
- `BeginModeExit` marks mode root + persistent passives + persistent interactives as exiting.
- `IsModeExitComplete` returns false before `startModeExitAnimations` runs.
- `IsModeExitComplete` returns true on the first call AFTER
  HandleFrame Phase 0 has run `startModeExitAnimations` when no
  exit anims are declared anywhere (empty ExitKeys vacuously
  complete per chunk 7 D105).
- `IsModeExitComplete` returns false during anim playback, true after duration elapsed.
- During TransitionExiting, RouteInput is skipped (D118).
- During TransitionExiting, policy aggregations return base values (D118).
- During TransitionExiting, OpenFlow directives are dropped (D118).
- `BeginModeExit` is idempotent (second call no-ops).
- `BeginModeExit` aggregates per-node errors from
  `MarkExitingForModeEnd` / `MarkModeRootExiting` (when their
  internal `routing.HandleExitStart` returns an error) and
  reports the joined error via `r.errorSink` with op
  `opRuntimeBeginModeExitFailed`. State mutations on individual
  nodes still commit even when their HandleExitStart fails;
  test asserts `node.Exiting == true` for every persistent node
  AFTER a failure-injecting HandleExitStart stub.
- `BeginModeExit` skips the mode root in the persistent-walk
  loop and routes the mode root through `MarkModeRootExiting`
  exactly once. Test injects a counter on
  `MarkExitingForModeEnd` and asserts it's NOT called for the
  root.

`game/interaction/runtime/cues_test.go`:
- `processCues` filters by `cueKindOpenFlow` (`"cue.ui.menu.open_flow"`);
  cues with other kinds are ignored.
- Valid open_flow payload → `attachOpenFlow` called with decoded request.
- Invalid open_flow payload → error returned, frame continues.
- Multiple open_flow cues in one batch → processed in order.
- Empty cue batch → no-op, no error.

`game/interaction/tree/tree_manager_test.go` (additions):
- `MarkExitingForModeEnd(persistent_node)` succeeds (no panic).
- `MarkExiting(persistent_node)` (non-mode-end path) panics (existing
  chunk 7 contract preserved).
- `MarkModeRootExiting` populates ExitKeys from root schema.

`engine/session/session_test.go` (additions):
- `ModeSession.CanSave()` interface method satisfiable by all
  per-mode session types.

`game/sessions_test.go` (or new file):
- `TitleSession.CanSave()` returns false (mode_save_policy = never).
- `OverworldSession.CanSave()` returns true (mode_save_policy = allowed).
- `BattleSession.CanSave()` returns true.
- Clone preserves canSave value.

`game/run_save_test.go` (new or existing):
- F5 save callback: title mode → gate blocks.
- F5 save callback: overworld with no flow open → gate proceeds.
- F5 save callback: overworld with a test-harness transient flow
  authoring `save_suppress = "while_visible"` open → gate blocks
  via policy.CanSave(tm) false.

`internal/importguard/import_guard_test.go`:
- Layer guard rules execute without referencing deleted packages.
- No package can import `game/menu` or `game/flow` (the rules don't exist
  to forbid the import — the packages don't exist, so import would fail
  at compile time, but the guard's allowed/forbidden lists are pruned
  for hygiene).

`game/integration_phase6_test.go`:
- Boot → title mode-root enter anim plays.
- Title menu Start Game → transition to overworld.
- Overworld mode-root enter anim plays on swap frame.
- Pause menu cue-driven attach → enter anim plays.
- Pause menu cancel → exit anim plays → reap.
- Overworld → battle transition (test harness directive).
- Battle proof passes inside the new mode.
- F5 in title blocked, F5 in overworld passes, F5 in pause blocked.
- Quit from title → exit anim plays → mc.quitAcknowledged
  flipped → mc.QuitRequested() returns true on next poll.

#### 11.2.5 Integration proof

Section 11.2.4's `integration_phase6_test.go` IS the integration
proof. After chunk 8, the test harness exercises every Phase 6
mechanism end-to-end with no legacy menu/flow code in the build.

#### 11.2.6 Validation gate

```
go run ./tools/cataloggen/ -check
go test ./engine/pipeline/...
go test ./engine/session/...
go test ./game/mode_controller_test.go
go test ./game/interaction/runtime/...
go test ./game/interaction/tree/...
go test ./game/...
go test -tags guard ./internal/importguard/...
go run ./tools/devctl testall
```

All nine must pass. Critically: no `import "afterimage/game/menu"`
or `import "afterimage/game/flow"` anywhere in the codebase.
Implementer verifies via grep before declaring chunk 8 complete.

### 11.3 Chunk 8 implementation notes

#### 11.3.1 Async transition state stays in ModeController

Per Q7 lock, the transition state model lives on ModeController,
NOT on the per-mode Runtime. ModeController is the singleton that
knows about both old and new modes during the swap; the Runtime
instance for the OLD mode stops being relevant the moment the
swap completes (it gets dropped, replaced by a new Runtime
constructed for the new mode at `enterMode`).

The Runtime exposes BeginModeExit + IsModeExitComplete (D115) as
the API ModeController calls; ModeController owns the
sequencing.

#### 11.3.2 Mode-root wrapping unifies with non-root wrapping

Chunk 7 D98 explicitly excluded the mode root. Chunk 8 reverses
that exclusion. The wrapping algorithm (chunk 7 D96 / D97 / D98)
is unchanged — wrapper at `JoinNodeID(rootSchemaID, instance_id, "lifecycle")`,
NodeKindUnknown, single child. The ID just happens to compose to
`<rootSchemaID>/<rootSchemaID>/lifecycle` for mode roots because
mode root's instance_id equals rootSchemaID per chunk 2 D26.

The chunk 7 D108b composition algorithm (battle_root authored,
children appended to its panel) continues to apply — but the
returned UINode is the wrapper, not the panel directly. The
panel is the wrapper's single child.

#### 11.3.3 Save format and load path are NOT modified

Per Otto's M6 clarification, the save file format
(`SaveFile{Version, Metadata, ECSWorld}`) is unchanged. The load
path that boots fresh-overworld is unchanged. Only the SAVE GATE
(chunk 8 D124) changes — it now reads `session.CanSave()` AND
`policy.CanSave(tm)` instead of legacy menu/flow flags.

This keeps chunk 8's risk surface tight: serialization formats
and load reconstruction logic don't move; only the per-frame
save-eligibility check is rewritten.

#### 11.3.4 `host.MenuState` engine-side type — implementer-verified deletion

`engine/ui/host.MenuState` is referenced by both the legacy
adapters (deleted) and possibly other code. The implementer
greps for remaining references after deleting `game/menu/` +
adapters; if only chunk 8's deletions used it, the engine-side
type also deletes. If other consumers exist (UI host runtime
side outside the deleted scope), the engine type stays.

The decision is deferred to implementation-time grep, not
pre-locked here, because the scope of `host.MenuState`'s reach
isn't fully visible from the spec layer.

#### 11.3.5 Cue producer site stays

The producer side of `cueKindOpenFlow` cues lives outside chunks
1-7's scope (likely in `engine/input/input.go` for Escape →
pause_menu, or in Lua scripts that emit cues via the cue sink).
Chunk 8 does NOT modify producers — it only changes the consumer
side (replacing `game/ui_cues.go`'s legacy filter loop with the
new `processCues` in `game/interaction/runtime/cues.go`).

The `Kind` string value `"cue.ui.menu.open_flow"` is preserved across the
migration so producers don't need updating. If chunk 8's
implementer finds the producer emits a different string today
(e.g., `"flow.open"` or similar), the chunk 8 constant matches
whatever the producer emits — not vice-versa.

#### 11.3.6 Surface-change footprint

Engine surface changes (small + additive):
- `engine/pipeline/host_effect_types.go` — `HostRequestKind`
  enum gains 2 values; `HostRequest` struct gains 1 field (D111, D112).
- `engine/pipeline.RuntimeControlHooks` interface — **unchanged**
  in chunk 8. Quit query continues via existing
  `QuitRequested() bool` method.
- `engine/runtime.TransitionPolicy` interface gains 1 method:
  `RequestQuit() error` (D112). Existing `RequestTransition` +
  `CurrentMode` are unchanged.
- `engine/runtime.HostEffectExecutor` gains a `transition
  TransitionPolicy` field + executor cases for
  `HostRequestModeTransition` and `HostRequestQuit` (D112).
- `engine/session.ModeSession` interface gains 1 method:
  `CanSave() bool` (D121).

ModeController additions:
- `TransitionPhase` state model + `pendingQuit` field (D114).
- `RequestQuit` method (D119).
- Async `applyPendingTransition` (D114).
- `runtimeForMode` helper (D114).

Runtime additions:
- `BeginModeExit` + `IsModeExitComplete` (D115).
- `modeExiting` flag + Phase-9-style exclusions (D118).
- `TreeManager()` accessor (D124).
- `processCues` impl (D120).

Tree-package additions:
- `MarkModeRootExiting` method (D115).
- `MarkExitingForModeEnd` path (D117).

Game-layer subtractions (the bulk of chunk 8's work):
- Delete `game/menu/` (12 files), `game/flow/` (19 files), 4
  game-layer adapter/helper files.
- Reshape `game/sessions.go` (subtraction + canSave).
- Delete `UseTreeRuntime` per-mode flag.
- Layer guard cleanups across chunks 1-7's forbidden lists.

Authored content additions (all schema files under
`game/assets/scripts/game/interaction/schemas/`, NOT the bridge
component files under `game/assets/scripts/game/ui/components/` —
animations are schema-level per chunk 7 D99 / D100):
- `title_root.lua` + `title_menu.lua` flesh-out (D125).
- `overworld_root.lua` flesh-out (D126).
- `battle_root.lua` enter/exit anims added (D126). The schema
  file gets the new fields; chunk 7's authored bridge component
  at `game/assets/scripts/game/ui/components/battle_root.lua`
  is unchanged in chunk 8.

---

## 12. Open decisions deferred to post-Phase-6 work (traceability)

Chunks 1-8 are fully spec'd. The Phase 6 spec is complete after
chunk 8 lands. Items below are explicitly out-of-scope for Phase
6 — they're either runtime concerns that aren't statically
validated, or content-driven extensions that land in their
authoring chunks (post-Phase-6).

- **Step `next` directive validation** → runtime concern; not
  statically validated.
- **Anchor enum extensions beyond `AnchorCenter`** → chunks that
  first author content needing a new anchor kind.
- **WorldUI population** → first chunk that authors world-anchored
  content. Chunk 7 D108b notes that mode-root components build
  the screen root only. Chunk 8 D126 keeps overworld on the
  chunk 3 D43 synthetic-root fallback (no overworld bridge
  component authored); chunk 8 D116 also locks that the
  ScreenUI mode-root wrapper does not extend to WorldUI in
  chunks 1-8. World-anchored content + WorldUI mode-root
  wrapping land together in whichever chunk first authors them.
- **Additional host-effect kinds beyond
  `SetWindowMode` / `OpenFlow` / `WarnNotImplemented` /
  `ModeTransition` / `Quit`** — chunk 4 D57 locks the
  `set_window_mode` mapping; chunk 8 D111 adds transition / quit
  kinds. Other authored host-effect kinds (cutscene transitions,
  audio cues, save trigger from in-game UI, etc.) land in their
  authoring chunks.
- **Visibility toggling** — chunk 6 D87 resolves this **as
  no-op**: `input_suppressed` does not toggle `FlowNode.Visible`.
  The `Visible` field exists for future flexibility but is not
  driven by chunks 1-8 mechanisms. Future use cases that need
  hiding-without-detach lock the semantics in their landing
  chunks.
- **Selection.Key from controller input handling** — chunk 6
  D81 locks the renderer side-table mechanism. Future controller
  input that doesn't fit "select on focused" (e.g., touch /
  pointer click) extends `runtime/selections.go` then.
- **In-session load** — `docs/systems/save_load.md` §h notes
  that in-session load (replacing the world under a live
  pipeline) is unsupported because 6 cached world holders would
  need updating. Post-Phase-6 engineering item; chunk 8 does not
  touch it.
- **In-game save trigger UI** — chunk 8 D124 retrofits the F5
  quicksave gate. A future chunk may author an in-game save UI
  (menu option that triggers save without F5). That chunk will
  add a new `HostRequestSave` kind (or similar) and route it to
  the same callback.
- **Save file format extensions** — chunk 8 D124 keeps the
  `SaveFile{Version, Metadata, ECSWorld}` format unchanged. If
  future content needs runtime-payload persistence (e.g.,
  saving mid-flow state), the save format gains a new field at
  that time, and the gate relaxes accordingly.

**Phase 6 status after chunk 8:** complete. All 8 chunks spec'd
and prompt-ready. Implementation prompts can begin.
